UE4 にて特定の Actor に対して輪郭線・クリース線・色の境界線を描画する、あるいはD言語くんを真の姿にする方法
「UE4 にインポートしたD言語くん1の 3D モデルに線を付けたい!」ということで、Custom
ノードを使ったポストプロセスマテリアル (以下、長いので PPM と略す) を作成しました。これについて解説していきます。
目次
始めに
前提知識
以下に挙げる おかず@pafuhana1213 さんの記事を読んでください (丸投げ)。
- UE4のポストプロセスマテリアルで色々してみた 基本編 - Unreal Engine 4 (UE4) Advent Calendar 2014 - ぼっちプログラマのメモ
- UE4のCustomノード(カスタムHLSLシェーダ)を使ってみた - ぼっちプログラマのメモ
これらを読めば、「そもそも PPM って何?」という人でも、輪郭線の描画を行う Custom
ノードを使った PPM を、ロジックを理解した上で作成できるようになるはずです。
注意
Custom
ノードは、現状 (4.6.2 & 4.7.1 preview 8) ではかなり不安定です。Custom
ノードを使った PPM を作成する際には、以下を守ることをオススメします。
- テスト用プロジェクトで作業を行い、問題がないことを確認してから本番プロジェクトに持ち込む
- 実行時エラーを引き起こす PPM を作成してしまうと、最悪の場合プロジェクトが編集不能になるため (具体例は後述)
Custom
ノードのCode
テキストボックスにおいて、範囲選択を伴う操作を行わない- 超高確率でエディタがフリーズするため
- 別のテキストエディタでコード編集を行う (範囲選択操作抜きのコード編集は地獄であるため)
- コードはテキストファイルとして保存しておく (範囲選択を伴う
Code
のコピーができないため) Code
を更新する際は、リセットボタンを押してからコードをペーストする (全範囲選択によるCode
全消去ができないため)
解説
少しずつ処理を足しながら解説をしていくので長いです。結果のコードだけ知りたい人は、最後まで飛ばして読んでください。
SceneDepth を用いて『輪郭線』を描画する
おかず@pafuhana1213 さんの記事 にて SceneDepth を用いて輪郭線を描画する PPM が解説されているので、まずはここから始めたいと思います。改変を加えていますが、やっていることの大筋は同じです。
/* DrawLine の Code */
const float1 laplacian8DirFilter[9] = {-1.0, -1.0, -1.0, -1.0, 8.0, -1.0,- 1.0, -1.0, -1.0};
float1 invLineSD, filResSD; /* SD : SceneDepth */
for(int i = -1; i <= 1; ++i) {
for(int j = -1; j <= 1; ++j) {
float2 currentUV = centerUV + float2(i, j) * invSize;
FScreenSpaceData ssd = GetScreenSpaceData(currentUV, false);
filResSD += smoothstep(0, depthMax, ssd.GBuffer.Depth) * laplacian8DirFilter[(i+1)+(j+1)*3];
}
}
invLineSD = floor(clamp((abs(filResSD) + strengthSD), 0, 1));
return float4(CalcSceneColor(centerUV), 0) * (1 - invLineSD);
strengthSD
という調整用パラメータを導入し、線が描画される箇所を増減できるようにしています。
作成した PPM を、以下のようなレベルに適用すると…
以下のようになりました。
strengthSD
= 0
では線がほとんど描画されません。strengthSD
= 0.99
とすると改善されますが、足の部分には線があまり描画されていません。これは、足と床の SceneDepth 値が近いため、エッジが検出されにくいからです。
strengthSD
= 0.9999
とすると足の部分にも線が描画されるようになりますが、余計な線が描画されるようになり、動かしてみると汚さが目立ちます。床の一部も真っ黒です。
CustomDepth を用いて『輪郭線』を描画する
- 周囲の深度に依らず、常に一定の輪郭線がほしい
- D言語くん以外には線は不要
ということで、SceneDepth の代わりに CustomDepth を使って輪郭線を描画することにします。
D言語くんの Actor を選択し、詳細タブ → Render → Render Custom Depth
のチェックをオンにします。
これで、D言語くんの深度情報のみが、CustomDepth に書き出されるようになります。
PPM を CustomDepth 仕様に書き換えます。CustomDepth の値は、FScreenSpaceData
の GBuffer.CustomDepth
で取得できます。
/* DrawLine の Code */
const float1 laplacian8DirFilter[9] = {-1.0, -1.0, -1.0, -1.0, 8.0, -1.0,- 1.0, -1.0, -1.0};
float1 invLineCD, filResCD; /* CD : CustomDepth */
for(int i = -1; i <= 1; ++i) {
for(int j = -1; j <= 1; ++j) {
float2 currentUV = centerUV + float2(i, j) * invSize;
FScreenSpaceData ssd = GetScreenSpaceData(currentUV, false);
filResCD += smoothstep(0, depthMax, ssd.GBuffer.CustomDepth) * laplacian8DirFilter[(i+1)+(j+1)*3];
}
}
invLineCD = floor(clamp((abs(filResCD) + strengthCD), 0, 1));
return float4(CalcSceneColor(centerUV), 0) * (1 - invLineCD);
結果は以下のようになりました。
Sobel Filter を用いてエッジ検出を行う
上の結果では少々線が細い気がします。エッジ検出に用いる Filter を Laplacian から Sobel に変更します。
/* DrawLine の Code */
const float1 sobelFilterH[9] = {-1.0, 0.0, 1.0, -2.0, 0.0, 2.0, -1.0, 0.0, 1.0};
const float1 sobelFilterV[9] = {-1.0, -2.0, -1.0, 0.0, 0.0, 0.0, 1.0, 2.0, 1.0};
float1 invLineCD; float1 filResCDH, filResCDV; /* CD : CustomDepth */
for(int i = -1; i <= 1; ++i) {
for(int j = -1; j <= 1; ++j) {
float2 currentUV = centerUV + float2(i, j) * invSize;
FScreenSpaceData ssd = GetScreenSpaceData(currentUV, false);
float1 CD = smoothstep(0, depthMax, ssd.GBuffer.CustomDepth);
filResCDH += CD * sobelFilterH[(i+1)+(j+1)*3];
filResCDV += CD * sobelFilterV[(i+1)+(j+1)*3];
}
}
invLineCD = floor(clamp((pow(filResCDH, 2) + pow(filResCDV, 2) + strengthCD), 0, 1));
return float4(CalcSceneColor(centerUV), 0) * (1 - invLineCD);
若干ではありますが、線が太くなりました。
WorldNormal を用いて『クリース線』を描画する
輪郭線だけでは線が足りないので、クリース線 (折り目部分の線) の描画処理を加えます。クリース部分の検出には WorldNormal を利用します。この値は FScreenSpaceData
の GBuffer.WorldNormal
で取得できますが、以下の 2 点に注意を払う必要があります。
特定の条件を満たさないと、WorldNormal の取得時にエディタが落ちる
理由はよくわからないのですが、Scene Texture Id
に WorldNormal
が設定されている SceneTexture
ノードがどこかで使われていないと、FScreenSpaceData
の GBuffer.WorldNormal
を取得する際にエディタが落ちます。
これが起こると、最悪の場合 プロジェクトを開く → PPM の処理が走る → エディタが落ちる のコンボが発動し、プロジェクトを一切編集できなくなります。その場合は、プロジェクトディレクトリから PPM の uasset を削除する必要があります。
WorldNormal は 3 次元ベクトル
法線情報は (x, y, z) の 3 次元ベクトルなので、深度情報と同じようには処理できません。最終的には何らかの方法で、線を書くか書かないか (あるいはその中間) という 1 次元情報に直す必要があります2。
以上に注意して、クリース線の描画処理を追加します。
/* DrawLine の Code */
const float1 sobelFilterH[9] = {-1.0, 0.0, 1.0, -2.0, 0.0, 2.0, -1.0, 0.0, 1.0};
const float1 sobelFilterV[9] = {-1.0, -2.0, -1.0, 0.0, 0.0, 0.0, 1.0, 2.0, 1.0};
float1 invLineCD; float1 filResCDH, filResCDV; /* CD : CustomDepth */
float1 invLineWN; float3 filResWNH, filResWNV; /* WN : WorldNormal */
float1 invLine;
for(int i = -1; i <= 1; ++i) {
for(int j = -1; j <= 1; ++j) {
float2 currentUV = centerUV + float2(i, j) * invSize;
FScreenSpaceData ssd = GetScreenSpaceData(currentUV, false);
float1 CD = smoothstep(0, depthMax, ssd.GBuffer.CustomDepth);
float3 WN = ssd.GBuffer.WorldNormal;
filResCDH += CD * sobelFilterH[(i+1)+(j+1)*3];
filResCDV += CD * sobelFilterV[(i+1)+(j+1)*3];
filResWNH += WN * sobelFilterH[(i+1)+(j+1)*3];
filResWNV += WN * sobelFilterV[(i+1)+(j+1)*3];
}
}
invLineCD = floor(clamp((pow(filResCDH, 2) + pow(filResCDV, 2) + strengthCD), 0, 1));
invLineWN = floor(clamp((dot(filResWNH, filResWNH) + dot(filResWNV, filResWNV) + strengthWN), 0, 1));
invLine = invLineCD + invLineWN;
return float4(CalcSceneColor(centerUV), 0) * (1 - invLine);
法線の x, y, z それぞれの要素についてフィルター処理を行った後、各要素を 2 乗して加算することで、線の情報に変換しています。「各要素を 2 乗して加算」という処理は、内積関数 dot
を用いて行っています。
結果はこのようになりました。
SceneColor を用いて『色の境界線』を描画する
まだ目の辺りの線が足りないので、SceneColor を用いた色の境界線の描画処理を加えます。この辺りまで来ると「テクスチャでやれ」感が出てきますが、D言語くんは塗りがハッキリしているので、今回は PPM で実現したいと思います。SceneColor の取得は CalcSceneColor
関数で行います。
/* DrawLine の Code */
const float1 sobelFilterH[9] = {-1.0, 0.0, 1.0, -2.0, 0.0, 2.0, -1.0, 0.0, 1.0};
const float1 sobelFilterV[9] = {-1.0, -2.0, -1.0, 0.0, 0.0, 0.0, 1.0, 2.0, 1.0};
float1 invLineCD; float1 filResCDH, filResCDV; /* CD : CustomDepth */
float1 invLineWN; float3 filResWNH, filResWNV; /* WN : WorldNormal */
float1 invLineSC; float3 filResSCH, filResSCV; /* SC : SceneColor */
float1 invLine;
for(int i = -1; i <= 1; ++i) {
for(int j = -1; j <= 1; ++j) {
float2 currentUV = centerUV + float2(i, j) * invSize;
FScreenSpaceData ssd = GetScreenSpaceData(currentUV, false);
float1 CD = smoothstep(0, depthMax, ssd.GBuffer.CustomDepth);
float3 WN = ssd.GBuffer.WorldNormal;
float3 SC = CalcSceneColor(currentUV);
filResCDH += CD * sobelFilterH[(i+1)+(j+1)*3];
filResCDV += CD * sobelFilterV[(i+1)+(j+1)*3];
filResWNH += WN * sobelFilterH[(i+1)+(j+1)*3];
filResWNV += WN * sobelFilterV[(i+1)+(j+1)*3];
filResSCH += SC * sobelFilterH[(i+1)+(j+1)*3];
filResSCV += SC * sobelFilterV[(i+1)+(j+1)*3];
}
}
invLineCD = floor(clamp((pow(filResCDH, 2) + pow(filResCDV, 2) + strengthCD), 0, 1));
invLineWN = floor(clamp((dot(filResWNH, filResWNH) + dot(filResWNV, filResWNV) + strengthWN), 0, 1));
invLineSC = floor(clamp((dot(filResSCH, filResSCH) + dot(filResSCV, filResSCV) + strengthSC), 0, 1));
invLine = invLineCD + invLineWN + invLineSC;
return float4(CalcSceneColor(centerUV), 0) * (1 - invLine);
結果はこちら。
CustomDepth を用いて線のマスキングを行う
D言語くん以外のオブジェクトにもクリース線と色の境界線が描画されてしまっているので、マスキングを行います。CustomDepth をマスクとして利用します。
/* DrawLine の Code */
const float1 sobelFilterH[9] = {-1.0, 0.0, 1.0, -2.0, 0.0, 2.0, -1.0, 0.0, 1.0};
const float1 sobelFilterV[9] = {-1.0, -2.0, -1.0, 0.0, 0.0, 0.0, 1.0, 2.0, 1.0};
float1 invLineCD; float1 filResCDH, filResCDV; /* CD : CustomDepth */
float1 invLineWN; float3 filResWNH, filResWNV; /* WN : WorldNormal */
float1 invLineSC; float3 filResSCH, filResSCV; /* SC : SceneColor */
float1 invLine;
for(int i = -1; i <= 1; ++i) {
for(int j = -1; j <= 1; ++j) {
float2 currentUV = centerUV + float2(i, j) * invSize;
FScreenSpaceData ssd = GetScreenSpaceData(currentUV, false);
float1 CD = smoothstep(0, depthMax, ssd.GBuffer.CustomDepth);
float3 WN = ssd.GBuffer.WorldNormal;
float3 SC = CalcSceneColor(currentUV);
filResCDH += CD * sobelFilterH[(i+1)+(j+1)*3];
filResCDV += CD * sobelFilterV[(i+1)+(j+1)*3];
filResWNH += WN * sobelFilterH[(i+1)+(j+1)*3];
filResWNV += WN * sobelFilterV[(i+1)+(j+1)*3];
filResSCH += SC * sobelFilterH[(i+1)+(j+1)*3];
filResSCV += SC * sobelFilterV[(i+1)+(j+1)*3];
}
}
invLineCD = floor(clamp((pow(filResCDH, 2) + pow(filResCDV, 2) + strengthCD), 0, 1));
invLineWN = floor(clamp((dot(filResWNH, filResWNH) + dot(filResWNV, filResWNV) + strengthWN), 0, 1));
invLineSC = floor(clamp((dot(filResSCH, filResSCH) + dot(filResSCV, filResSCV) + strengthSC), 0, 1));
invLine = invLineWN + invLineSC;
/* マスキングを行い、CustomDepth 書き出し領域内の線だけを残す */
invLine *= (1 - smoothstep(0, depthMax, GetScreenSpaceData(centerUV, false).GBuffer.CustomDepth));
invLine += invLineCD;
return float4(CalcSceneColor(centerUV) * (1 - invLine), 1.0);
- クリース線と色の境界線を合成する
- マスク処理を行い、D言語くんのみに線を残す
- 輪郭線を合成する
という順番で処理しています。マスク処理の後に輪郭線の合成を行わないと、線が細るので注意です。
これで完成しました!
実際に動いている様子はこんな感じです (1080p 60fps でお楽しみください)。
終わりに
素晴らしい記事を書いてくださった おかず@pafuhana1213 さんに感謝致します!
-
プログラミング言語Dの公式マスコットキャラクター (Overview - D Programming Language)。可愛い (確信)。 ↩
-
色トレスなどを実現したいのであれば、この限りではありません。 ↩