3
2

Unreal Engineで自作シェーダ(usf)を操るTips

Last updated at Posted at 2024-05-31

概要

Unreal Engineでシェーダ(usf)だけで画面に描画する方法を解説したいと思います。マテリアルやStatic Meshは使いません。

やっていることは言葉にするとシンプルで、BasePassの直後にグローバルシェーダで頂点変換、任意テクスチャやデプスバッファの読み込み等をしながら任意の描画をGBufferに対して行います。

しかし、DirectX等で自前で描画することがそうであるように多くのステップを踏む必要があり、それぞれをUnreal Engineの流儀で行う必要があります。

Unreal Engineのバージョンは5.4ですが、当初5.3も使用しており、以下内容は5.3でも有効です。

成果物

このシェーダを使って作った動画です。海の描画が自作のusfです。

(HDR対応動画になっており、HDRのモニタとブラウザで見ることをおすすめしますがSDRでも見られます)

プラグイン化

グローバルシェーダーと呼ばれる仕組みを用いるには、usfを含む独自プラグインをプロジェクトに追加する必要があります。

ハマりやすく、ハマって悩みやすい点なので強調しますが、upluginに"PostConfigInit"を忘れず指定します。

"LoadingPhase" :"PostConfigInit"

FSceneViewExtensionBaseの派生クラスの扱い

GBufferに介入するため、FSceneViewExtensionBaseから派生したクラスを作りました。

class FOceanWaveSceneViewExtension : public FSceneViewExtensionBase
{
public:
	virtual void PostRenderBasePassDeferred_RenderThread(FRDGBuilder& GraphBuilder, FSceneView& InView, const FRenderTargetBindingSlots& RenderTargets, TRDGUniformBufferRef<FSceneTextureUniformParameters> SceneTextures) override;
    ...
};

実現したい形としては、アクターを起点にFOceanWaveSceneViewExtensionを生成することです。使用者視点で、アクター配置が海描画の条件となり、アクターのプロパティが海の設定値となります。

image.png

海はエディタ編集中、ゲームプレイ中、いずれの場合も表示してほしいので一工夫必要となりました。
最終的に以下の4つをオーバーライドすることで実現できました。

UCLASS()
class OCEANWAVE_API AOceanWaveActor : public AActor
{
	GENERATED_BODY()
	
public:	
	AOceanWaveActor();

protected:
	// ゲームモードで海を生成
	virtual void BeginPlay() override;

	// エディタモードで海を生成
	virtual void OnConstruction(const FTransform& Transform) override;

	// ゲームモードで海の表示切り替え
	virtual void Tick(float DeltaTime) override;

	// エディタモードで海の表示切り替え
	virtual void SetIsTemporarilyHiddenInEditor(bool bIsHidden) override;
    ...

private:
	TSharedPtr<FOceanWaveSceneViewExtension, ESPMode::ThreadSafe> OceanWaveSceneViewExtension;
    ...
};

また、上記の通りFOceanWaveSceneViewExtensionのTSharedPtrを直接アクターに持たせています。上記の4関数で表示非表示の切り替えを検知したら、下記のようにTSharedPtrをnullptrにするかインスタンスを設定するかします。

void AOceanWaveActor::ApplyVisibility(bool bVisible)
{
	if (!bVisible && OceanWaveSceneViewExtension.IsValid())
	{
		OceanWaveSceneViewExtension = nullptr;
	}

	if (bVisible && !OceanWaveSceneViewExtension.IsValid())
	{
		OceanWaveSceneViewExtension = FSceneViewExtensions::NewExtension<FOceanWaveSceneViewExtension>();
	}
}

ところで、OnConstructで作ったエディタモード用の海がゲーム実行中やBP等アセットのプレビュー画面にも描画されてしまう問題があり、以下のようにPostRenderBasePassDeferred_RenderThreadの中で描画を実際にするかどうかの判定を入れる必要がありました。

void FOceanWaveSceneViewExtension::PostRenderBasePassDeferred_RenderThread(FRDGBuilder& GraphBuilder, FSceneView& InView, const FRenderTargetBindingSlots& RenderTargets, TRDGUniformBufferRef<FSceneTextureUniformParameters> SceneTextures)
{
	switch (InView.Family->Scene->GetWorld()->WorldType)
	{
	case EWorldType::PIE:	// avoid drawing editor instance in PIE
		if (bIsEditorInstance)
		{
			return;
		}
		break;

	case EWorldType::EditorPreview: // avoid drawing on content browser icons, BP editor and so on...
		return;
	}
    ...
}

マルチスレッドの対応

FOceanWaveSceneViewExtensionはレンダースレッドで実行されるため、ゲームスレッドからのプロパティ変更などは注意が必要となります。ここは、スレッド間プロパティ値受け渡し用に構造体を宣言しておくと排他処理が楽です。

// クリティカルセクションとゲームスレッドから受け取るプロパティ値
class FOceanWaveSceneViewExtension : public FSceneViewExtensionBase
{
    ...
	mutable FCriticalSection CriticalSection;
 	FOceanWaves OceanWaves_GameThread;
    ...
}
// ゲームスレッドからプロパティ指定
void FOceanWaveSceneViewExtension::SetOceanWaves(FOceanWaves InOceanWaves)
{
	FScopeLock Lock(&CriticalSection);
	OceanWaves_GameThread = InOceanWaves;
	bDirty = true;
}
// レンダースレッドからプロパティ利用
void FOceanWaveSceneViewExtension::BuildUniformBuffer()
{
	FOceanWaves OceanWaves;
	{
		FScopeLock Lock(&CriticalSection);
		OceanWaves = OceanWaves_GameThread;
	}
    ...
}

前述のFOceanWavesはゲルストナー波のパラメータ値を含んでおり、ゲームスレッド・レンダースレッド・GPU間でパラメータ共有が実現しています。以下のようにGPUで描画した波の上にゲームスレッドが物を浮かべることが可能です。
image.png
image.png

GBufferとDepth Stencilバッファについて

FSceneViewExtensionBaseを継承して実装するPostRenderBasePassDeferred_RenderThread関数の引数RenderTargetsは各種レンダーターゲットやデプスステンシルバッファが渡されており、これをシェーダを用いて書き換えることができます。

試した限り、以下のようにレイアウトされており、それぞれ自作シェーダから書き換えることができました。

コード 意味 セマンティクス
RenderTargets.Output[0] scene color SV_Target0
RenderTargets.Output[1] world normal SV_Target1
RenderTargets.Output[2] r:Metallic b:Specular g:Roughness a:ShadingModelId SV_Target2
RenderTargets.Output[3] base color SV_Target3
RenderTargets.Output[4] subsurface color SV_Target4
RenderTargets.DepthStencil depth stencil

scene colorのシェーダにおける役割はエミッシブ出力先です。よって、1より大きい値を出力することも可能です。

ShadingModelIdは整数値ですが、出力の際は255で割る必要がありました。整数値はマテリアルエディタのShading Modelの並びになっています。(Unlitは0, Default Litは1... )

image.png

Depth Stencilは、デプスバッファとして前後判定に用いることは勿論ですが、シェーダーリソースとしてバインドして例えば"Depth Fade"ノードと同等の実装を行うことも可能でした。後述します。

GBufferに関してはCGWORLDの記事も参考にさせていただきました。

注意点として、プロジェクト設定のSubstrate materialsをONにするとGBufferのバインド過程でクラッシュしました。GBufferの実装が変わっているのかもしれません。今回はSubstrate materialとの共存は試していません。

シェーダ動かすために準備するリソース一覧

今回、vertex shader, pixel shader の2つだけの最低構成だけで海を作りました。
DirectX11で必要とされるリソース郡を揃える程度には大変ですが、DirectX12やVulkan程大変ではありません。また、UnrealがAPIの差異等をラップしてくれています。

準備するもの やりかた 備考
vertex shader FGlobalShader継承
pixel shader FGlobalShader継承
pipeline state object FGraphicsPipelineStateInitializer構造体 BlendState, RasterizerState, DepthStencilStateを含む
index buffer FIndexBuffer継承 なくてもよい
vertex buffer FVertexBuffer継承 なくてもよい
vertex declaration FRenderResourceを継承し、FVertexDeclarationRHIRefを作る なくてもよい
uniform buffer BEGIN_UNIFORM_BUFFER_STRUCTマクロで定義し、TUniformBufferRef<>::CreateUniformBufferImmediateで生成、TUniformBufferRef型で保持 なくてもよい
shader parameter BEGIN_SHADER_PARAMETER_STRUCTで各シェーダクラス内に定義、FRDGBuilder::AllocParametersで確保 各種バインド可能な型や数値を定義
texture shader parameterに設定する
sampler shader parameterに設定する

多くの概念はDirectX APIと共通していますが、DirectXでいうConstant Bufferに近い概念が二種類存在し、それぞれ使い方が異なります。

省略 usf内宣言 シェーダー間使いまわし 使用可能な型
shader parameter 省略不可 自分で宣言を書く必要あり 不可能 数値、テクスチャ、サンプラ、uniform buffer
uniform buffer 省略可 自動生成 可能 数値、テクスチャ、サンプラ

それぞれConstant Bufferの機能も内包しつつも、テクスチャやサンプラなどのリソースを同時にバインドする機能を有することがわかります。

shader parameterは、各pixel shaderやvertex shaderに固有です。また、毎描画ごとに値を設定もしくはリソースをバインドしなおす必要があります。

一方、uniform bufferは、各シェーダーとは独立しています。各シェーダーで使用したいuniform bufferがある場合はshader parameterを経由してバインドします。

リソースのバインドと値渡し

公式ドキュメントのRDGのページも参考になります。

C++側からバインドできる型はマクロで定義されています。多くはshader parameterとuniform bufferで共通で使われます。

マクロ 渡すもの 備考
SHADER_PARAMETER floatやFVector3fなどの変数値渡し float, FVector2f, FVector3f, FVector4f, FIntPoint, FIntVector4, uint32, int32, int, unsigned, FMatrix44f, FLinearColor, HLSLで基本型相当で単精度の型の使用が確認できますが、任意の構造体や倍精度は使用例がない(使えない?)ようです。ただし、FScreenTransformという構造体がfloat4として、FIntRectがuint4としてバインドされる例はありました
SHADER_PARAMETER_ARRAY SHADER_PARAMETERの配列版
SHADER_PARAMETER_STRUCT_REF uniform buffer shader parameterのみ?
SHADER_PARAMETER_STRUCT_INCLUDE shader parameter 他で宣言したshader parameterを取り込む
SHADER_PARAMETER_TEXTURE RHIテクスチャ
SHADER_PARAMETER_RDG_TEXTURE RDGテクスチャ
SHADER_PARAMETER_SAMPLER サンプラー
RENDER_TARGET_BINDING_SLOTS レンダーターゲット, depth stencilバッファ pixel shaderのshader parameterのみ
SHADER_PARAMETER_STRUCT 構造体 今回は使わず、実装がトリッキー
SHADER_PARAMETER_STRUCT_ARRAY 構造体の配列 今回は使わず、実装がトリッキー
SHADER_PARAMETER_SRV SRV 様々な型のSRVを渡せる。例えば構造体の配列をStructuredBufferで渡せる

SHADER_PARAMETERシリーズはこれ以外も存在し、ShaderParameterMacros.h に定義されています。全て使ってみたわけではないため把握しきれていません。

SHADER_PARAMETERでFVector3fと、floatが32bitであることを明示している点に注意です。Unreal Engine 5はFVectorがdouble型になっているため、代入の際は型変換してあげる必要があります。

SHADER_PARAMETER_STRUCT_REFは、uniform bufferをバインドするものです。

テクスチャ型が二種類あることも興味深い点です。SHADER_PARAMETER_TEXTURE は、テクスチャアセットなどRHIテクスチャの実体がある場合使います。一方、SHADER_PARAMETER_RDG_TEXTUREはdepth stencil bufferをシェーダリソースとして渡す時に使いました。depth stencil bufferはRDGが実行されるまでRHIリソースの実体が存在しないためです。

RENDER_TARGET_BINDING_SLOTSは、マルチレンダーターゲットおよびdepth stencil bufferをまとめてバインドします。

shader parameterの宣言のコードです。(解説用で、実際と多少異なる)

class FOceanWaveShaderPS : public FGlobalShader
{
	DECLARE_EXPORTED_GLOBAL_SHADER(FOceanWaveShaderPS, OCEANWAVE_API);
	SHADER_USE_PARAMETER_STRUCT(FOceanWaveShaderPS, FGlobalShader);

	BEGIN_SHADER_PARAMETER_STRUCT(FParameters, )
		SHADER_PARAMETER(float, MeshScale)
		SHADER_PARAMETER(FVector3f, MeshOffset)
        SHADER_PARAMETER_STRUCT_REF(FViewUniformShaderParameters, View)
		SHADER_PARAMETER_STRUCT_REF(FOceanWaveCppToPS, OceanWaveCppToPS)
		SHADER_PARAMETER_TEXTURE(Texture2D, TextureToDraw)
		SHADER_PARAMETER_SAMPLER(SamplerState, TextureToDrawSampler)
		SHADER_PARAMETER_RDG_TEXTURE(Texture2D, MyDepthStencil)
		SHADER_PARAMETER_SAMPLER(SamplerState, MyDepthStencilSampler)
		RENDER_TARGET_BINDING_SLOTS()
	END_SHADER_PARAMETER_STRUCT()
};

BEGIN_SHADER_PARAMETER_STRUCT ~ END_SHADER_PARAMETER_STRUCT がshader parameterの宣言です。見ての通りpixel shaderのクラス宣言の中に書かれています。(vertex shaderでも同様)

shader parameterとして定義した値は、以下のようにusfファイルにも対応する値を宣言する必要があります。

float MeshScale;
float3 MeshOffset;
Texture2D TextureToDraw;
SamplerState TextureToDrawSampler;
Texture2D MyDepthStencil;
SamplerState MyDepthStencilSampler;

uniform bufferの宣言は以下の形になります。

// VSにわたすuniform buffer
BEGIN_UNIFORM_BUFFER_STRUCT(FOceanWaveCppToVS, )
	SHADER_PARAMETER(float, WaveSteepness)
	SHADER_PARAMETER(int32, NumWaves)
	SHADER_PARAMETER(float, HeightMapToVertexImpact)

 	// Waves[i].xy: 論文の「Di」。波の方向
	// Waves[i].z: 論文の「Ai」。Amplitude。水面から山の山頂までの高さ
	// Waves[i].w: 論文の「L」。波長、波の長さ、山から山までの距離
	SHADER_PARAMETER_ARRAY(FVector4f, Waves, [MaxOceanWaves])

	// Waves2[i].x: 論文の「S」。波の速度
	SHADER_PARAMETER_ARRAY(FVector4f, Waves2, [MaxOceanWaves])

    SHADER_PARAMETER_TEXTURE(Texture2D, WaterBumpTexture)
	SHADER_PARAMETER_SAMPLER(SamplerState, WaterBumpTextureSampler)
END_UNIFORM_BUFFER_STRUCT()

BEGIN_UNIFORM_BUFFER_STRUCT ~ END_UNIFORM_BUFFER_STRUCT がuniform bufferの宣言です。マクロが展開されるとクラスの宣言としてコンパイルされます。(STRUCTとありますがclassになっています)

uniform bufferをshader parameterにバインドするとuniform bufferを一つの構造体してインスタンスを定義するコードが自動生成されます。

そのためにはC++に実体を宣言する必要があり、以下のようにします。

IMPLEMENT_UNIFORM_BUFFER_STRUCT(FOceanWaveCppToVS, "OceanWaveCppToVS");

この例では"OceanWaveCppToVS"がHLSLにおける構造体のインスタンス名になり、usfでは以下のようにアクセスできます。

    float WaveSteepness = OceanWaveCppToVS.WaveSteepness;
    int NumWaves = OceanWaveCppToVS.NumWaves;
    float4 WaterBump = OceanWaveCppToVS.WaterBumpTexture.SampleLevel(OceanWaveCppToVS.WaterBumpTextureSampler, UV, 0)

OceanWaveCppToVSは内部的にコンスタントバッファとして定義されていますが、不思議なことにコンスタントバッファへ含められないテクスチャオブジェクトやサンプラーオブジェクトのバインドまで、あたかもコンスタントバッファの一部であるかのように書けます。実はテクスチャオブジェクトの場合、内部的にOceanWaveCppToVS_WaterBumpTextureという名前で定義され、プログラマが書いたコードの.が_に置き換えられます。この挙動はSaved\ShaderDebugInfo に生成されるコードから確認できます。

エンジンコード内は同じマクロの別名も使われています。
BEGIN_GLOBAL_SHADER_PARAMETER_STRUCT は BEGIN_UNIFORM_BUFFER_STRUCTの別名、
END_GLOBAL_SHADER_PARAMETER_STRUCT は END_UNIFORM_BUFFER_STRUCT の別名、
IMPLEMENT_GLOBAL_SHADER_PARAMETER_STRUCTは、IMPLEMENT_UNIFORM_BUFFER_STRUCTの別名です。
~_UNIFORM_BUFFER_~のほうが推奨されているようですが、~_GLOBAL_SHADER_PARAMETER_~のほうが多いです。

Unrealのシェーダでは場合により構造体の配列は使えないものと思ったほうが良いかもしれません。FOceanWaveCppToVS.Wavesは本来構造体の配列にしたいところですが、FVector4fの配列で代用しています。理由はSHADER_PARAMETER_STRUCT_ARRAYの使用方法の把握と実装に一手間掛かりそうであったためです。"PathTracingPostProcessInput"や"PostProcessInput"を全文検索するとSHADER_PARAMETER_STRUCT_ARRAYの使用方法について雰囲気を掴めるかと思います。

2024/06/20 追記
C++からHLSLに構造体の配列を渡したいときは、SHADER_PARAMETER_SRVを用いてStructuredBufferを渡すことで達成できます。別途記事にしました。

頂点フォーマットの作り方

例えば以下のような頂点フォーマットの構造体を作ったとします。

struct FDrawInWorldVertex
{
	FVector3f Position;
	FVector4f Color;
};

これをシェーダーに教えるには、以下のようなクラス及びグローバルなインスタンスを宣言します。

class FDrawTriangleVertexDeclaration : public FRenderResource
{
public:
	FVertexDeclarationRHIRef VertexDeclarationRHI;

	virtual void InitRHI(FRHICommandListBase& RHICmdList) override
	{
		FVertexDeclarationElementList Elements;
		uint32 Stride = sizeof(FDrawInWorldVertex);
		Elements.Add(FVertexElement(0, STRUCT_OFFSET(FDrawInWorldVertex, Position), VET_Float3, 0, Stride));
		Elements.Add(FVertexElement(0, STRUCT_OFFSET(FDrawInWorldVertex, Color), VET_Float4, 1, Stride));

		VertexDeclarationRHI = RHICreateVertexDeclaration(Elements);
	}

	virtual void ReleaseRHI() override
	{
		VertexDeclarationRHI.SafeRelease();
	}
};
static TGlobalResource<FDrawTriangleVertexDeclaration> GDrawTriangleVertexDeclaration;

FVertexElementの第4引数であるInAttributeIndexの数値は、それぞれシェーダからはATTRIBUTE0, ATTRIBUTE1... という「ATTRIBUTE+数値」の形をとった名前のセマンティクスに対応します。

void DrawInWorldVS(
    float3 Position : ATTRIBUTE0,
    float4 Color : ATTRIBUTE1, 
    ...

シェーダ内で頻出のView及びResolvedViewを使えるようにする

Common.ushのように、便利な関数を集めているヘッダファイルをincludeして利用するためにはその中で使われている変数 View 及び ResolvedView を使えるようにします。または、自前で頂点変換するためにも必要です。まず、Viewを使えるようにする方法は、前述の、

SHADER_PARAMETER_STRUCT_REF(FViewUniformShaderParameters, View)

の行をshader parameterに追加します。
FViewUniformShaderParametersは、カメラ行列やプロジェクション行列、GameTimeなど時空に関する多くの情報が含まれております。

FViewUniformShaderParametersの定義は SceneView.h にあり、BEGIN_GLOBAL_SHADER_PARAMETER_STRUCT_WITH_CONSTRUCTORで検索すると出てきます。また、カメラ行列を使うために必要な変数等は VIEW_UNIFORM_BUFFER_MEMBER_TABLE マクロで分けて定義されています。

また、ResolvedView を使用可能にするには、各シェーダのエントリーポイントで以下の1行を記述します。

	ResolvedView = ResolveView();

ViewとResolvedViewの同名変数は等価です。それに加えてResolvedViewにViewにはない追加のベクトルや行列があります。頂点変換やカメラ座標取得に必要なものですが、後述します。

FViewUniformShaderParametersはuniform bufferです。内容はエンジンが毎フレーム更新してくれています。ただし、バインドだけは以下のようにシェーダー開発者自ら行う必要があります。

#include "SceneRendering.h"

void FOceanWaveSceneViewExtension::PostRenderBasePassDeferred_RenderThread(FRDGBuilder& GraphBuilder, FSceneView& InView, const FRenderTargetBindingSlots& RenderTargets, TRDGUniformBufferRef<FSceneTextureUniformParameters> SceneTextures)
{
	check(InView.bIsViewInfo);
	const FViewInfo& ViewInfo = static_cast<const FViewInfo&>(InView);

	FOceanWaveShaderPS::FParameters* PSParameters = GraphBuilder.AllocParameters<FOceanWaveShaderPS::FParameters>();
	PSParameters->View = ViewInfo.ViewUniformBuffer;
    ...
}

SceneRendering.h は Engine\Source\Runtime\Renderer\Private\SceneRendering.h というパスであり、Privateフォルダ下は通常includeが不可能ですが、プラグインの.Build.csファイルである OceanWave.Build.cs に以下を記述することで可能になります。

		PublicIncludePaths.AddRange(
			new string[] {
				// ... add public include paths required here ...
				Path.Combine(GetModuleDirectory("Renderer"), "Private"),
			}
			);

Large World Coordinates導入に伴う頂点変換とカメラ座標処理の考慮事項

先にコードを示すと、ワールド座標からNDC座標(Unreal Engine内では"Clip"と称されるようです)への変換や、カメラ座標の取得は以下のようにできます。

void OceanWaveVS(out float4 OutNDCPosition : SV_POSITION)
{
	ResolvedView = ResolveView();

    ...

    // カメラ座標の取得
    float3 WorldCameraPos = LWCHackToFloat(ResolvedView.WorldCameraOrigin);

    // ワールド座標からNDC座標への変換
    float3 WorldPosition = ...
    OutNDCPosition = mul(float4(WorldPosition, 1.0f), LWCHackToFloat(ResolvedView.WorldToClip));
}

頂点変換やカメラ座標の取得を考えた時、Large World Coordinatesに触れる必要があります。Unreal Engine 5ではシェーダコード内でfloat変数を2つ束ねてdoubleを代替したり、カメラ位置を原点として計算することでfloatの計算精度を確保する機構が導入されています。

この文書によると、先のLWCHackToFloatの使用はその名が示す通り最善ではありません。LWCHackToFloatは手っ取り早くWorld座標系を扱う考え方でコーディングできる意味で便利でしたが、オープンワールドゲーム等において精度の問題が発生しうる上に計算負荷も高い、と言えそうです。

ワールド座標からNDC座標を求める例でいうと、以下の a) と b) は試した限りでは同じ結果が得られました。理想を言うと、シェーダー内(特にpixel shader)の'WorldSpace'は全て'TranslatedWorldSpace'に置き換えるべきかもしれません。

    // a)
    float3 WorldPosition = ...
    float3 WorldCameraPos = LWCHackToFloat(ResolvedView.WorldCameraOrigin);
    float3 TranslatedWorldPosition = WorldPosition - WorldCameraPos;
	float4 NDCPosition = mul(float4(TranslatedWorldPosition, 1.0f), ResolvedView.TranslatedWorldToClip);

    // b)
    float3 WorldPosition = ...
	OutNDCPosition  = mul(float4(WorldPosition, 1.0f), LWCHackToFloat(ResolvedView.WorldToClip));

ResolvedView及びPrimaryViewの追加メンバーを作っているFinalizeViewStateという関数があります。InstancedStereo.ushに定義されていますが、Saved/ShaderDebugInfo フォルダ下に生成されたコードは以下の通りになっています。

void FinalizeViewState(inout ViewState InOutView)
{
	InOutView.WorldToClip = MakeDFInverseMatrix(InOutView.ViewOriginHigh, InOutView.RelativeWorldToClip);
	InOutView.ClipToWorld = MakeDFMatrix(InOutView.ViewOriginHigh, InOutView.ClipToRelativeWorld);
	InOutView.ScreenToWorld = MakeDFMatrix(InOutView.ViewOriginHigh, InOutView.ScreenToRelativeWorld);
	InOutView.PrevClipToWorld = MakeDFMatrix(InOutView.ViewOriginHigh, InOutView.PrevClipToRelativeWorld);
	InOutView.WorldCameraOrigin = MakeDFVector3(InOutView.ViewOriginHigh, InOutView.ViewOriginLow);
	InOutView.WorldViewOrigin = MakeDFVector3(InOutView.WorldViewOriginHigh, InOutView.WorldViewOriginLow);
	InOutView.PrevWorldCameraOrigin = MakeDFVector3(InOutView.PrevWorldCameraOriginHigh, InOutView.PrevWorldCameraOriginLow);
	InOutView.PrevWorldViewOrigin = MakeDFVector3(InOutView.PrevWorldViewOriginHigh, InOutView.PrevWorldViewOriginLow);
	InOutView.PreViewTranslation = MakeDFVector3(InOutView.PreViewTranslationHigh, InOutView.PreViewTranslationLow);
	InOutView.PrevPreViewTranslation = MakeDFVector3(InOutView.PrevPreViewTranslationHigh, InOutView.PrevPreViewTranslationLow);
	InOutView.TileOffset.WorldCameraOrigin = MakeLWCVector3(InOutView.ViewTilePosition, InOutView.RelativeWorldCameraOriginTO);
	InOutView.TileOffset.WorldViewOrigin = MakeLWCVector3(InOutView.ViewTilePosition, InOutView.RelativeWorldViewOriginTO);
	InOutView.TileOffset.PrevWorldCameraOrigin = MakeLWCVector3(InOutView.ViewTilePosition, InOutView.PrevRelativeWorldCameraOriginTO);
	InOutView.TileOffset.PrevWorldViewOrigin = MakeLWCVector3(InOutView.ViewTilePosition, InOutView.PrevRelativeWorldViewOriginTO);
	InOutView.TileOffset.PreViewTranslation = MakeLWCVector3(-InOutView.ViewTilePosition, InOutView.RelativePreViewTranslationTO);
	InOutView.TileOffset.PrevPreViewTranslation = MakeLWCVector3(-InOutView.ViewTilePosition, InOutView.RelativePrevPreViewTranslationTO);
}

このコードは、PrimaryViewもResolvedViewも経由します。公式はPrimaryViewの使用を勧めているようですが、今回の使用目的上は等価に見えます。

Depth Fade 相当を自前で行う

水面は不透明(Translucentではない)ですが、Depth Stencilバッファを読み込んでマテリアルのDepth Fadeノード相当の処理を行い、水面の色を変える事が可能でした。

image.png

以下のFadeValueは、depthが描画ピクセルの位置から30cm以上あれば0, 一致に近づく程1へ近づきます。

Texture2D MyDepthStencil;
SamplerState MyDepthStencilSampler;

float MyLookupDeviceZ(float2 ScreenUV)
{
	return Texture2DSampleLevel(MyDepthStencil, MyDepthStencilSampler, ScreenUV, 0).r;
}

float MyCalcSceneDepth(float2 ScreenUV)
{
    return ConvertFromDeviceZ(MyLookupDeviceZ(ScreenUV.xy));
}

void OceanWavePS(..., FOceanWaveVSToPS In)
{
	ResolvedView = ResolveView();


    // depth fade ノード相当の計算を行い、波打ち際の判定を行う
	float2 ScreenUV = ScreenAlignedPosition(In.NDCPosition);
    float SceneDepth = MyCalcSceneDepth(ScreenUV.xy);
    float PixelDepth = In.NDCPosition.w;
    float FadeValue = 1 - saturate((SceneDepth - PixelDepth) / 30.0f);

    ...
}

ConvertFromDeviceZはDepth Bufferの値をビュー空間での奥行き(cm単位)に変換します。
ScreenAlignedPositionはNDC座標をDepth BufferのUVに変換します。
それぞれCommon.ushに定義されており、それぞれViewとResolvedViewを参照するため使う前にそれらを有効に保たなければなりません。

NDCPositionはvertex shaderで頂点変換したNDC座標です。SV_POSITION以外のセマンティクスを自分で定義してSV_POSITIONへと同等の値を出力しておきます。(なぜならSV_POSITIONは特殊で、pixel shaderにはvertex shader出力値を補間した値ではなく、スクリーン座標が入ってきます。それは今回欲しい値ではありません)

In.NDCPosition.wはビュー空間での奥行き(cm単位)になります。

C++側で用意するコードは以下の通りです。デプスバッファをシェーダリソースにすると同時に"RenderTarget"にもバインドしています。

void FOceanWaveSceneViewExtension::PostRenderBasePassDeferred_RenderThread(FRDGBuilder& GraphBuilder, FSceneView& InView, const FRenderTargetBindingSlots& RenderTargets, TRDGUniformBufferRef<FSceneTextureUniformParameters> SceneTextures)
{
    ...

    FOceanWaveShaderPS::FParameters* PSParameters = GraphBuilder.AllocParameters<FOceanWaveShaderPS::FParameters>();
	PSParameters->View = ViewInfo.ViewUniformBuffer;
	PSParameters->MyDepthStencil = RenderTargets.DepthStencil.GetTexture();
	PSParameters->MyDepthStencilSampler = TStaticSamplerState<SF_Bilinear, AM_Wrap, AM_Wrap, AM_Wrap>::GetRHI();
	
	PSParameters->RenderTargets.DepthStencil = FDepthStencilBinding(RenderTargets.DepthStencil.GetTexture(), ERenderTargetLoadAction::ELoad, ERenderTargetLoadAction::ENoAction,
		(bUseDepthBlend ? FExclusiveDepthStencil::DepthRead_StencilNop : FExclusiveDepthStencil::DepthWrite_StencilNop)
	);
    ...
}

Depth Fade的処理をしたいか、水面のDepthを書き込みたいかを選べるようにしました。これはバインド時のFExclusiveDepthStencilの指定で動作が変わります。

  • FExclusiveDepthStencil::DepthRead_StencilNop ... depth を読み込みたい場合
  • FExclusiveDepthStencil::DepthWrite_StencilNop ... depth に書き込みたい場合

Depth Fadeを実装しつつ自分自身のdepthを書き込むことはできませんでした(自分自身のdepthが返る)。両方同時にするにはDepth Bufferの複製を事前に作ってそれをシェーダーリソースにするなどの工夫が必要になりそうです。

尚、いずれもdepth bufferは"RenderTarget"へは必ずバインドが必要です。しないと海が無条件に他のメッシュの手前に描かれてしまいます。

”DefaultTexture"可視化の回避について

エディタ起動直後など、テクスチャとして”DefaultTexture"が誤ってバインドされ、意図しない絵が可視化されることがありました。

以下コードを実行すると、テクスチャアセット(UTexture2D)が"DefaultTexture"であるかを判断できます。この判断により、描画またはUniform Bufferへのテクスチャのバインドを保留し、テクスチャの実体のロードを待機します。

#if WITH_EDITOR
	if (TextureToDraw->IsDefaultTexture())
	{
		return false;
	}
#endif

GetDefaultTexture2Dという関数を検索すると"DefaultTexture"の実装が確認できます。チェッカーボード風のテクスチャや単色テクスチャがそれぞれUTexture2Dの実体を持っており、アセットのUTexture2Dの実体(テクセルなど)のロードが行われる前にそれらにすり替えられるような動作をします。

shader parameterとしてバインドする場合はアセットの実体がロードされた時点で表示が置き換わるのでまだよかったのですが、uniform bufferの場合"DefaultTexture"のRHIリソースをバインドしてしまうといつまでもそれが表示され続ける挙動となり、注意が必要でした。

海の描画方法について

  • 大きな波はゲルストナー波
  • 小さな波はvertex shaderでハイトマップの多重スクロール
  • 微細な凹凸はpixel shaderで法線マップの多重スクロール

ゲルストナー波は GPU Gems の記事を参考にさせて頂きました。

頂点の移動はEquation 9、法線はEquation 12をそのままコードにしています。法線マッピングも行いたいので、tangent, binormalもvertex shaderで算出させています。それぞれ、Equation 10とEquation 11をコードにしています。

細かい波はゲルストナー波ではなくハイトマップの多重スクロールで頂点を動かしています。

海は2560メートル四方で、カメラから近い場合はQuad Treeで分割していきます。大体20~30枚が描画されます。波打たせるため各板は一列に数百の頂点を持ちます。今の実装では一律に一列256頂点で海全体では400万ポリゴン程です。Quad Treeが効果的に作用していると評価できる一方、ゲーム用途ではよりアグレッシブな最適化が必要と言えそうです。(実際、Unreal EngineのWater Pluginで同様のシーンを作るとこの1/10程のポリゴン数でした)

image.png

GraphBuilder.AllocParametersで確保したパラメータは、複数ドローコールでパラメータの内容が同じである限り使い回せるようです。パラメータがドローコール毎に異なる場合は都度GraphBuilder.AllocParametersで確保する必要がありました。今回pixel shader用は同一で良いため使いまわし、vertex shader用座標やスケール値がそれぞれユニークなので都度確保しています。

本題から外れるトピックス

DaVinci ResolveでHDR動画を作る方法を以下に別途まとめました。

カメラの動きはバーチャルカメラを使用してつけました。UE5.4からAndroidに対応しました。UE5.4へのアップグレードの動機はこれです。

音楽はUDIOというサービスで生成しました。

潜在的課題

World Partitionの使用、モバイルデバイス、VRデバイスなどで試していないので、これら環境で問題が発生するかもしれません。

まとめ

  • グローバルシェーダ作成の実践的な例を網羅的に解説した
  • アクター起点で自由にGBufferに描画するパスを挿入できることがわかった
  • 用途により「グローバルシェーダ縛り」は労力が割に合わないかもしれない(メッシュとマテリアルで素直に作ったほうが安いかもしれない)
  • メッシュとマテリアルを用いる「普通」の作り方と距離があり、応用が難しいかもしれない
3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2