rarilog

主にゲーム制作に関することを書いていきます

2015/02/22 : UnrealEngine

UE4 にて特定の Actor に対して輪郭線・クリース線・色の境界線を描画する、あるいはD言語くんを真の姿にする方法

「UE4 にインポートしたD言語くん1の 3D モデルに線を付けたい!」ということで、Custom ノードを使ったポストプロセスマテリアル (以下、長いので PPM と略す) を作成しました。これについて解説していきます。

目次

始めに

前提知識

以下に挙げる おかず@pafuhana1213 さんの記事を読んでください (丸投げ)。

これらを読めば、「そもそも 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 の値は、FScreenSpaceDataGBuffer.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 を利用します。この値は FScreenSpaceDataGBuffer.WorldNormal で取得できますが、以下の 2 点に注意を払う必要があります。

特定の条件を満たさないと、WorldNormal の取得時にエディタが落ちる

理由はよくわからないのですが、Scene Texture IdWorldNormal が設定されている SceneTexture ノードがどこかで使われていないと、FScreenSpaceDataGBuffer.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);
  1. クリース線と色の境界線を合成する
  2. マスク処理を行い、D言語くんのみに線を残す
  3. 輪郭線を合成する

という順番で処理しています。マスク処理の後に輪郭線の合成を行わないと、線が細るので注意です。

これで完成しました!

実際に動いている様子はこんな感じです (1080p 60fps でお楽しみください)。

終わりに

素晴らしい記事を書いてくださった おかず@pafuhana1213 さんに感謝致します!

  1. プログラミング言語Dの公式マスコットキャラクター (Overview - D Programming Language)。可愛い (確信)。

  2. 色トレスなどを実現したいのであれば、この限りではありません。