ShaderGraphでToonShaderを作ろう②~エッジの作成~

はじめに

こんにちは!3D課のともちん(小笠原)です!今年もアドカレも残りわずかとなりましたね。残りの日数を大切に悔いのない2025年を過ごしましょう!では、今回のアドカレは前回作成したToonShaderの続きになります!エッジを検出して黒い線を描画して、よりアニメ調の見た目にしていく方法を解説します。

前回までのおさらいと今回の目標

さて、前回は鏡面反射と拡散反射光を閾値処理することでToonな見た目のShaderを作成しました。(以下の図のようになっているはず..)

今回はこのShaderをさらに“アニメ調”にすべく、エッジを作成していきます。エッジとは線のことであり色の変化するところに黒色の線を追加して、色の境界をより明確にします。

図を見ると球体と背景の境界や光沢の部分に黒い線が入っていることがわかりますね。今回はスクリーン空間でSobelフィルタを畳み込み計算してエッジを実装していきます!少し難しいかもしれませんが、頑張ってやって見ましょう。(画像情報処理1を履修していると理解が進みやすいと思います)

Sobelフィルタの理論について

人間がエッジと認識するのは目に映る物体の変化であり、それは物体との位置関係色の違いが鍵になります。この差を検出して必要な箇所にエッジを描画しなくてはなりません。

今回はスクリーンに描画される動画像を入力としてSobelフィルタを適用します。Sobelフィルタとは画像処理分野において画像上の色の急激な変化を検出する微分フィルタのことです。画像は極小のピクセルと呼ばれる小さな正方形の集合体で構成されています。ピクセルが画像中の位置ごとに異なる色を保持・表示することで、僕たちは画像を保存して見ることができています。エッジとなる部分は人間の目で見ると、色が急激に変化しているところになります。画像中のピクセルの色情報を入力として、周辺の8つ色情報との色の変化、すなわち勾配(変化量)を微分によって求め、エッジとして検出します。画像は連続的ではなく、離散的であり、毎フレーム処理を行う必要がある今回の事例では正確に微分を処理しつつ、処理速度をもとめることは難しいため、近似的な微分を行なって、エッジを検出します。近似的な微分フィルタはXY方向ごと以下の3*3行列で求められます。中央に重みとして2が付加されているのは、中心画素に隣接しており、そのピクセルにおいて最も色の変化の大きさを表しているためです。

このフィルタをスクリーン中の全ての各ピクセルに適用していきます。計算は畳み込み計算と呼ばれる特殊な計算を使用します。(そんなに難しくはない)これは図を見てみた方が早いのでまず図を見せます。

エッジかどうかを求めたいピクセル(赤色の正方形)に対してX方向Sobelフィルタを適用します。フィルタ中心と求めたいピクセルを照らし合わせるように配置して、周辺近傍8画素と求めたいピクセルの色の値とフィルタの乗算をそれぞれ計算します。乗算結果を中心画素に全て加算すると、そのピクセルの近似微分結果を得ます。この計算を全てのピクセルごとXY方向に計算した値をそれぞれGx,Gyとして、画像中の地点における最終的なエッジの強さGは G = sqrt(Gx^2 + Gy^2)で求められます。このGの値を画像ごとに閾値処理(Step)を行うと、エッジとなる部分は白色(1),そうでない部分は黒色(0)を求めることができます。

実装してみよう

今回はこのSobelフィルタをピクセル情報の中の色の値と深度値のそれぞれに適用し、その出力結果の比較から3D空間のエッジを検出します。深度値(Z値)とはカメラから物体までの距離のことです。画像処理のような2次元空間におけるエッジ検出では色の値のみのSobelフィルタで十分ですが、今回は3D空間であるため、背景と物体が同じ色の場合にもしっかりと検出すべく、深度値を使います(背景と物体では深度値の違いが大きく出るため)。計算方法は色の値の時と同様で、スクリーン上のエッジかどうか判定したいピクセルと近傍8つのピクセルが描画した物体のポイントの位置を元にSobelフィルタを適用します。 

まず、設定から深度値をCameraから取得できるようにする必要あります。また、今回はPostProcessEffect(スクリーンの画面効果を適用すること)を使ってEdgeを描画するので、そちらの設定も行います。(おそらくこれで行けるはず…)

①[Edit] => [ProjectSettings] => GraphicsのDefaultRenderPipelineにセットされているURPAssetsを開きます。InspectorからRendering の項目を確認し、DepthTextureとOpaqueTextureにチェックを入れます。

②作成したShaderGraphをMaterialにアタッチ. URPAssetsのDefalutに設定されているUniversal Renderer Dataを確認。AddRendererFeature からFull Screen Pass Renderer Feature を追加. アタッチしたMaterialをPassMarterialに設定.

③作成したUnilit Shader Graphの[Graph Settings]でDepth Writeをオフに設定します。

④PackageManagerからPostProcessing をインストール. MainCameraに[Post-Process-Layer]Componentをつける.

⑤Emptyの新規オブジェクトを作成. [PostProcessVolume] Componentをつける.IsGlobalに☑️を入れる

さて、実装の部分に入っていきます.前回同様ノードを繋いでShaderを作成していきますが、今回は実行中に近傍の画素や深度値を取得する必要があるので、ノードの機能を自分で定義できるCustomFunctionを使います。

右クリック CreateからCustomFunctionを作成します。[Graph Inspector]の[NodeSetting]から入力と出力の個数と形式を指定します(今回は Vector2とfloat を入力に, float を出力に設定)Sourceには作成した.hlslファイルを指定し, Nameにはhlslファイル内の呼ばれる関数名を指定します。HLSLファイルは以下の通りになっています.

#ifndef SOBELOUTLINES_INCLUDED
#define SOBELOUTLINES_INCLUDED

#endif

//サンプルする近傍画素の指定用配列
static float2 samplePoint[9] = {
    float2(-1, -1), float2(0, -1), float2(1, -1),
    float2(-1,  0), float2(0,  0), float2(1,  0),
    float2(-1,  1), float2(0,  1), float2(1,  1)
};

//Sobelのフィルタ X方向
static float sobelfilterX[9] = {
     -1, 0, 1,
     -2, 0, 2,
     -1, 0, 1 
};

//Sobelのフィルタ Y方向
static float sobelfilterY[9] = {
    1, 2, 1,
    0, 0, 0,
    -1, -2, -1
};


//深度値のSobelフィルタ適用
void DepthEdgeDetection_float(float2 uv, float thickness, out float Out)
{

    //深度値のSobelフィルター計算用変数
    float2 depth_sobel = 0;

    for(int i=0; i<9; i++)
    {
       //SHADERGRAPH_SAMPLE_SCENE_DEPTH()で指定ピクセルの深度値(Z値)をGET
       float depth = SHADERGRAPH_SAMPLE_SCENE_DEPTH(uv+samplePoint[i] * thickness); //深度値サンプリング
       depth_sobel += depth * float2(sobelfilterX[i], sobelfilterY[i]);    //X方向, Y方向のフィルターがけ

    }
    Out = length(depth_sobel);
    
}

//ピクセルごとにこの関数を呼ぶ カラーのSobelフィルタ適用
void ColorEdgeDetection_float(float2 uv, float thickness, out float Out)
{
    //GrayScale化するよりも RGBごとで計算した結果の最大値取る方が良い結果が得られるらしい
    float2 sobel_R = 0;
    float2 sobel_G = 0;
    float2 sobel_B = 0;
    
    float2 col_sobel = 0;
    float2 pixelSize = float2(1/_ScreenParams.x, 1/_ScreenParams.y);

    for(int i=0; i<9; i++)
    {
        //SAMPLE_TEXTURE2D()で指定ピクセルの色をGET
        float3 col = SAMPLE_TEXTURE2D(_BlitTexture, sampler_BlitTexture, uv + samplePoint[i] * thickness * pixelSize).rgb;  //カラー値サンプリング
        sobel_R += col.r * float2(sobelfilterX[i], sobelfilterY[i]);    //X方向, Y方向のフィルターがけ R
        sobel_G += col.g * float2(sobelfilterX[i], sobelfilterY[i]);    //X方向, Y方向のフィルターがけ G
        sobel_B += col.b * float2(sobelfilterX[i], sobelfilterY[i]);    //X方向, Y方向のフィルターがけ B

    }

    //RGBの中での最大値をSobelの結果として出力
    Out = max(length(sobel_R), max(length(sobel_G),length(sobel_B)));


}

ColorEdgeDetection_float(float2 uv, float thickness, out float Out)

UV座標(スクリーン座標)を入力で受け取り, RGBごとにSobelフィルタを計算し、RGBの3つの中で最も大きな微分結果を持つ値を出力します。

DepthEdgeDetection_float(float2 uv, float thickness, out float Out)

計算対象ピクセルとその周辺近傍8ピクセルの深度値を取得し、Sobelフィルタを計算することで距離の差によるエッジを検出しています 設定したノードは以下の通りになります。

CustomNodeのSourceに作成した.hlslファイルを指定し, Nameに呼び出したい関数ColorEdgeDetectionを選択します。他、使用するノードは以下の通りになります。

  • UV (スクリーン座標を指定)
  • Thickness (float型のユーザーパラメータ. Sobelフィルタのサンプリング距離を指定)

Nodeを組むと以下の通りになります。同様に深度値Sobelフィルタの関数DepthEdgeDetection_float()もNodeを組みます。

次に2つのSobel計算結果を合成する準備として微小なエッジ検出結果を計算によって炙り出します。使用するノードは以下の通りです。出力されたSobel結果を閾値処理で2値化し、1に近い値をPowerによって強調しています。

  • SmoothStep(閾値処理。閾値以上の値は1に、それ以下は0に2値化)
  • Power(累乗計算. 1に近い値のみを強調して残す。)
  • Multiply(乗算処理)

最後に2つSobel結果の最大値を取り、スクリーン画像と合成していきます。スクリーンの画像はSampleTexture2DNodeを作成し、 Texture2Dの[NodeSettings]から[Reference]に_BlitTextureを指定します。使用するノードは以下の通りです。

  • Blend(スクリーン画像とSobel結果を合算する)
  • SampleTexture2D(カメラの写すスクリーン画像の取得用. )
  • Maximum(2つの入力から最大値を出力)

まとめ

出力結果の例を以下に示します。いろんなものに適用すると、良さげな感じがします。背景との境界はもちろん、光沢部分にもエッジが黒色で描画されていていいですね。

さて、いかがだったでしょうか。今回の二つの記事からShaderに興味を持っていただければ幸いです。Shader以外にもUnityの中には多くの機能や理論があります。そういったものを体系的に学んでより良いゲーム制作に取り組んでいきましょう!!(超⭐︎ 大事!!!!!)

コメントを残す

CAPTCHA