【UE5で気分転換!セルルック×ライティングでキャラを魅せたい】第5回:ライティング 陰影編 後編 – Unreal Engine 5.6

Unreal Engine

水ノ茉の宣伝

準備中...
ゲームを作る予定なの
水ノ茉こおり

シリーズ説明

本シリーズで最終的に検証したい内容は、DMXで制御されたバカみたいな量のライト、それらすべてをキャラクターに当てても破綻しないレンダリングパイプラインの構築です。

実装の手段は選ばないため、普段展開している【UE5】◯◯◯ – Unreal Engine ◯.◯と比べると多少敷居が高く、また、実装の再現性を提供しないこともあるため、いつも以上に趣味嗜好が全開となります。

始まり

前回、冷却と言いつつ結局はいつも通り、勢いだけで陰影実装の基礎部分を終わらせてしまいました。

結果としてバカみたいに疲れました。

今回は問題点の修正や軽い最適化、残りの陰影、明暗実装を進めていきます。

陰影がパカパカ切り替わる問題の修正

陰影を正規化することで多光源な環境でも、セル・トゥーン調っぽい明暗を表現できます。

この手法、見た目は良いのですが、既知の欠陥があります。正規化する際に参照する最小値と最大値は、カメラや描画物の位置関係、アニメーションによって大きく変動します。その変動が毎フレームのように続いてしまうと、動画のように陰影がパカパカ切り替わってしまうのです。

検証したい抑制方法はひとつ、前フレームのバッファをプールして、変更量をクランプすることです。変更量が極大な場合は遅れているような見た目になる懸念は拭えませんが、そもそも想定している方法で抑制可能か分からないので、まずはお試し実装です。LumenやTSR、AO系の設計を参考に作っていきます。

FPreviousViewInfoToonShadowBufferの履歴であるFToonShadowHistoryを所有してもらいます。バッファを1枚だけなのでTRefCountPtrで直書きでも良かったのですが、2つの関数をラッピングしておきたかったので構造体という形を採用です。

Bufferの場合はFRDGPooledBufferTextureの場合はIPooledRenderTargetです。

//// CoffeeLive @kobayashi-arata 2025/06/22 ////
struct FToonShadowHistory
{
	TRefCountPtr<FRDGPooledBuffer> Buffer;

	void SafeRelease()
	{
		Buffer.SafeRelease();
	}

	bool IsValid() const
	{
		return Buffer.IsValid();
	}
};
//// CoffeeLive @kobayashi-arata 2025/06/22 ////

履歴は前フレームが存在しない場合はヌルポです。

const FToonShadowHistory& InputHistory = View.PrevViewInfo.ToonShadowHistory;
FToonShadowHistory* OutputHistory = View.ViewState ? &View.ViewState->PrevFrameViewInfo.ToonShadowHistory : nullptr;

履歴が存在する場合はレンダーグラフに登録して使えるようにします。

if (InputHistory.IsValid())
{
	HistoryToonShadowMinMaxBuffer = GraphBuilder.RegisterExternalBuffer(InputHistory.Buffer, TEXT("ToonShadow.History.MinMaxBuffer"));
	HistoryToonShadowMinMaxSRV = GraphBuilder.CreateSRV(HistoryToonShadowMinMaxBuffer, PF_FloatRGBA);
}
else
{
	HistoryToonShadowMinMaxSRV = GraphBuilder.CreateSRV(GSystemTextures.GetDefaultBuffer(GraphBuilder, 256, FVector4f(0.0f)), PF_FloatRGBA);
}

シェーダーに履歴の有無のバリエーションを用意です。

FToonShadowFinalizeCS::FPermutationDomain PermutationVector;
PermutationVector.Set<FToonShadowFinalizeCS::FValidHistory>(InputHistory.IsValid());

TShaderMapRef<FToonShadowFinalizeCS> ComputeShader(View.ShaderMap, PermutationVector);

シェーダーはシンプルなクランプです。DeltaPerFrameがフレームごとに許容される変更量です。

#if VALID_HISTORY == 1
	float4 HistoryToonShadowMinMax = HistoryToonShadowMinMaxBuffer[DispatchThreadId];
	if (!any(isnan(HistoryToonShadowMinMax)))
	{
		// 前フレームとの変更量
		float ToonShadowMinDiff = ToonShadowMin - HistoryToonShadowMinMax.x;
		float ToonShadowMaxDiff = ToonShadowMax - HistoryToonShadowMinMax.y;

		// 変更量を制限して反映
		ToonShadowMin = HistoryToonShadowMinMax.x + clamp(ToonShadowMinDiff, -DeltaPerFrame, DeltaPerFrame);
		ToonShadowMax = HistoryToonShadowMinMax.y + clamp(ToonShadowMaxDiff, -DeltaPerFrame, DeltaPerFrame);
	}
#endif  // VALID_HISTORY

最後に履歴バッファを更新して終了です。

if (OutputHistory)
{
	OutputHistory->SafeRelease();
	GraphBuilder.QueueBufferExtraction(ToonShadowMinMaxBuffer, &OutputHistory->Buffer);
}

抑制前は胸の下あたりが明らかにチカチカしています。

抑制後はスカートに落ちる腕の影がチラつく程度で大幅に抑制されています。

むふふふふ。

ぼく!てんさい!いえーい!

頭の中の世界、設計がその通りに形になるとやっぱり楽しいですね。

ご機嫌です。

あとはカメラカットのタイミングで履歴をクリアできるようにPermutationVectorにセットして作業は完了です。

const bool bCameraCut = View.bCameraCut || View.bForceCameraVisibilityReset || View.bPrevTransformsReset;

これで多光源な環境でもセル・トゥーン調っぽい陰影を良い感じに落とせるようになりました。

しかし当初の懸念通り、変更量に制限を設けているので、近くに光源がある場合は遅延を認識出来ちゃいます。画面外のスポットライトやDMXでブンブン振り回すライトたちの場合は、無視できる問題な気がするので一旦は保留とします。パカ付くよりは、よっぽどいいでしょう。

ポイントライトのシャドウ対応を忘れてた

なんかポイントライトの影が落ちねぇと思ったら、TODOが残されていました。おそらく過去の私が対応面倒過ぎてサボったのでしょう。

現在こと未来で対応して完了です。

胸の下あたりに影が出来るの良いですよね。

好みの問題なんでしょうが、セル・トゥーンルックでCSMな髪影はやっぱり違和感を感じますね。負荷的にシャドウデプスの分離は厳しい気もしますが、ちょっと解決したい問題になりそうな予感です。

顔陰ツールの対応

CoffeeLiveだけで使うツールではないですが、ついでなので5.6対応と、全探索対応、距離計算をテクスチャ座標とモデル座標の2つから選択式に拡張します。

当初の予定ではリファクタがある程度終わったら再公開でしたが、自分でも使いこなすのが難しいツール、他人が使えるわけないじゃん、という理由から御破算です。

アーティストに依存せず顔陰を作れる環境を用意するという当初の目的は達成できたので問題ないのですが、なんかもう少しイテレーションが早くなるようなプリセットを色々を設けたい、だけどそれを作るのは自分なんよな、面倒だから気が向いたらやろう、以降無限ループです。

テクスチャ座標とモデル座標

素体であるこの子、UV調整までされているのでモデル座標で距離計算しなくても普通に出来そうなんですよね。という適当な理由を付けてテクスチャ座標にも対応しちゃいます。元々はBlenderで自動的に展開した割と位置関係が適当なUVでも動作させるために、モデル座標を元に距離計算していただけなので、同じような結果が得られるなら座標系はなんでも良いのです。

オリジナル3Dモデル『ミルティナ』 - DOLOS art - BOOTH
【ミルティナ試着ワールド】 ----------------------------------------------------- オリジナル3Dモデル【Milltina】 製作者:DOLOS VRChatと動画配信等でのご利用をお勧め...

ありゃ。すごいですね。モデル座標で計算したSDFの結果とほぼ一緒じゃないですか。はぇぇ。

UV展開もそのうち勉強しないとですね。

シーム付けて『おりゃぁ!』しか出来ん。

手作業でやるか、プラグインを実装するか、どちらにするかは不明です。

バックグラウンド対応

今まではレンダースレッドが終了するまでゲームスレッドも待機していたのですが、エディタが止まって普通に使い勝手が悪いので、中間テクスチャをプールして複数フレームを跨いで処理するように改修します。

GraphBuilderを作成したら絶対にExecuteして退出してください。完結してないぞ♪って怒られます。

あとは普通にFTextureRHIRefな中間リソースをCreateRenderTargetを通してTRefCountPtr<IPooledRenderTarget>に保持して、次フレームで使う時にRegisterExternalTextureしているというシンプルなやり方です。

void UToonShadePaintSubsystem::Tick_RenderThread(FRHICommandListImmediate& RHICmdList, FRDGBuilder& GraphBuilder)
{
	// 処理
}

void UToonShadePaintSubsystem::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);

	ENQUEUE_RENDER_COMMAND(ToonShadePaintBlueprintLibrary_CreateShadowThresholdMap)(
		[this](FRHICommandListImmediate& RHICmdList)
	{
		if (/*リクエストがない限りはリターン*/)
		{
			return;
		}

		FRDGBuilder GraphBuilder(RHICmdList);
		Tick_RenderThread(RHICmdList, GraphBuilder);
		GraphBuilder.Execute();
	});
}

全探索対応

複数フレームを跨げるようになったのでより負荷の高い全探索をします。先の対応を入れる前は1フレームで処理を完結していたのですが、その状態で全探索をやるとGPUがタイムアウトしちゃって出来なかったんですよね。

関数と変数名は適当祭りです。気が向いたらリファクタします。

[numthreads(32, 32, 1)]は当然SV_GroupIndexが 0 ~ 1023 なので、それを元にgroupsharedに突っ込んでグループ内で値を共有しています。あとは縦と横に32×32ずつズラして全探索しているだけです。32*32にUNROLLが指定されていないのは意図的です。ここはLOOPにしないとファイルサイズがぶっ飛んでかえって遅くなります。

groupshared float4 SharedColor[1024];

FTestNano UnpackTestNano(int Index)
{
	FTestNano Out;
	Out.Position = SharedColor[Index].xyz;
	Out.Flags    = asuint(SharedColor[Index].w);
	return Out;
}

FTestNano FeachTestNano(int2 Coord)
{
	FTestNano Out;
	Out.Position = PositionTexture[uint2(Coord)].xyz;
	Out.Flags    = SeedFlagsTexture[uint3(Coord, LayerIndex)].r;
	return Out;
}

[numthreads(32, 32, 1)]
void MainCS(uint3 DispatchThreadId : SV_DispatchThreadID, uint3 GroupID : SV_GroupID, uint GroupIndex : SV_GroupIndex)
{
	for (int i = StartX; i < EndX; ++i)
	{
		int2 Coord = (DispatchThreadId.xy + int2(i * 32.0, OffsetY)) % TextureSize;

		PackTestNano(FeachTestNano(Coord), GroupIndex);
		GroupMemoryBarrierWithGroupSync();

		for (int j = 0; j < 32 * 32; ++j)
		{
			FTestNano TestNano = UnpackTestNano(j);
			if (TestNano.IsValid())
			{
				// 処理
			}
		}

		GroupMemoryBarrierWithGroupSync();
	}
}

謎ノイズ

ドット抜けのような謎現象と遭遇しました。

スケールによって再現性が確保できます。

うん。分からん。中間計算の可視化がある現状ではパラメータ調整で回避可能な問題なので一旦は放置とします。

ゼロ除算はケアしてるから、ゼロベクトルな正規化あたりが怪しいとは思うんですが、それっぽい箇所もぱっと見は無いんですよねぇ。

とはいえ画面に出ている以上、なにかしらの不備はあるのでしょう。

未来の私、後始末よろしくです。

ガウスブラーによる後処理

マスク表現をする際に発生しやすい問題です。SDFなので内外で最短距離を計算しているのですが、狭い面積内で最短距離をすると短すぎて精度が劣化、結果的に陰がガビガビになります。

参考元の実装は後処理としてガウスぼかし入れていたので、同じく入れちゃいます。

動画だと思ったよりバレなさそうでしたが、静止画だと改善が目に見えて分かりますね。欠点としては全体的に丸みを帯びてしまうのですが、ガビガビするより、断然良いです。

作成フロー

未来の私に向けた記録なの。

PaintMaskInvalidFillで塗り潰します。

距離計算が必要な面積は照射面積のみで必要以上に設けると余計な地点から最短距離を求めちゃうから最初にInvalidで塗り潰すのが吉です。

PaintNoneInvalidMask陰を作成する場所を大きめに用意します。

InvalidをMaskした範囲内で陰を作る感じです。陰を出したい範囲ぴったりでマスクしちゃうと顎部分のカーブが不自然に曲がるから、たぶん気に入らないと思います。

PaintFillInvalidNoneで角度ごとの陰を大雑把に作成してカーブ度合いを確認します。

顎部分の曲線は余白によって変化しやすいので、まずは大雑把に0, 45, 90, 125, 135度でブツ切り円柱を回転させて、InvalidMaskの範囲に問題が無いかを確認です。

角度はちゃんと線形の中間点であることを忘れずにです。補間間隔をズラしちゃうと特定の角度区間だけ明暗の遷移速度が不自然に加減速しちゃいます。

余白がないとカーブの終端がお外に跳ねやすいです。

余白があるとカーブの終端が比較的安定します。

マスク表現を作る場合はこんな感じでFillMaskを上手いこと使って大小を作ると良い感じになります。

顔陰の多光源対応

この環境でSDFを適用した時の見た目が気になるので覗いてみます。

閾値をGバッファに突っ込みます。

顔陰計算に必要なベクトルはGrimでコンポーネント化しているので拝借です。

顔が含まれるスケルタルメッシュにタグを付与して。

ベクトルを取得したいソケット名を指定して。

素体によって軸が異なるので、顔の前方と右ベクトルになるように変換を通したら準備完了です。

改造環境だとMaterialInstanceDynamicにアクセスせずにグローバルなコンスタントバッファでやり取りをしています。それによりゲームとレンダースレッド間の伝達が最小限に抑えられるため負荷的に最適解なのです。SetVectorParameterValueはスレッド間なやり取りなので地味に重いんですよね。Grimの舞台は学園なので生徒の大量描画に耐えられる基底設計が必要だったのです。

ポイントやスポットはライトベクトルが頂点座標からの差分になるので、細かすぎるかなと思いましたが、意外と耐えていますね。

おわり!!!

陰影の正規化のパカパカ問題を無事解消できて満足です。

クロスシミュ味を感じる。

セルルック関連

雑談

添付動画のフレームレートを60にしてビットレートを積んだらサイトのスクロール速度がバカ遅くなりました。

いつも使っている30フレの高圧縮設定が安牌でしたね。

元々はGIFのためのプリセットだったんですが、意外と都合が良かったということに気付けました。

一部コンテンツのスクロール速度が謎に遅かったんですが、これでようやく原因が分かりました。

気が向いたら置換しましょうか。

やや不健全なクロスマスターへの道

Grimで制服のスカートと胸のあたりに皺を作りたくて始めたクロスシミュ。

セルフコリジョンがないので、スカートが吹き飛ぶような風力を与えると貫通しちゃう点に目を瞑れば、それっぽいですね。

残りは処理をGPUに移管、StaticとSkeletalのインターフェース設計に対応、この辺りができればある程度は現実味を帯びそうですね。未だに指折り算しないと碌に四則演算出来ないクソザコ筆者さんにはかなり難しい領域である物理演算ですが、揺らせるとエッチなのです。揺らすこと自体は全年齢ですが、視点を変えるとエッチ味を感じる。むふふです。