【Unity】Sobel Filterを使ってリムライト表現を実装する
はじめに
この記事は Colorful Palette アドベントカレンダー 12/22 の記事です。
こんにちは!株式会社Colorful Paletteでクライアントエンジニアをしているとぐちです。
主にグラフィックス・描画関連の担当をしています。
3DCGでキャラクターを描画する際に「Toonシェーダー」がよく使用されています。
この記事では、そのToonシェーダーの機能として頻繁に実装される、オブジェクトに当たる逆光や輪郭の光を表現する「リムライト」表現を実装するためのアプローチについて解説します。
実装環境
Unity 2022.3.12f1
Universal Render Pipeline 14.0.9
実装 - 準備
まず、Toonシェーダとしての体裁を整えるために最小限の機能を実装します。今回は以下の機能を実装しました。
Half Lambertの2値化を用いた陰影表現
反転ポリゴンを押し出す方式でのアウトライン表現
これらの表現が入る前後の比較が以下の画像です。
ここからはこの表現が乗っている前提でリムライト表現の解説をしていきます。
(記事の末尾におまけとしてシェーダー全文も添付します)
実装 - フレネル効果によるリムライト
次に、Toonシェーダーでリムライト表現を実装する際にポピュラーな「フレネル効果」を使った実装を比較対象として解説しておきます。
フレネル効果によるリムライトの一般的な実装では、描画するオブジェクトの法線と視線方向の内積を計算することでオブジェクトのエッジ(法線が急な部分)を求め、その部分に色を加算します。
以下にサンプルコードを記述します。
// Rim (Fresnel)
// 法線と視線方向の内積を取る
half fresnel = saturate(dot(normalWS, normalize(IN.viewDirWS)));
// 外側を1とするため反転
half rim = 1.0 - fresnel;
// 閾値をもとに2値化
rim = step(_RimThreshold, rim);
color.rgb += rim * _RimColor.rgb;
この実装では、はっきりとした形状のリムライトを出すため(また、後述するSobel Filter方式の実装との比較をしやすくするため)閾値をもとにした2値化を行っています。
この表現が入ると、以下のような見た目になります。
フレネル効果方式の課題点
フレネル効果方式の実装でも、ぱっと見はきれいにエッジに沿って光が当たっているような表現にはできているのではないかと思います。
ただ、いくつかの場合で課題があります。
まず1点目は、オブジェクトの内側にもライトが乗ってしまう点です。
以下の画像では赤く塗りつぶされている部分にオブジェクトの内側にも関わらずライトが乗ってしまっています。
この現象の原因は、フレネル効果がオブジェクトの外側・内側に関わらず法線と視線方向の角度が急な箇所すべてに乗ってしまうことにあります。
この現象はアートスタイルによっては特に問題ない(逆に良いとされることもある)ですが、外側にのみライトを乗せたい場合は課題となります。
2点目は、ライトの太さが一定にならない場合が多い点です。
以下の画像の角度だと、顔の広範囲にライトが乗ってしまっています。
こちらもアートスタイル等によっては特に問題のない挙動ではありますが、表現として常に一定の太さのライトを乗せたい場合は課題となります。
(フレネル方式でも頂点法線を調整することである程度調整は可能ですが、かなり難しいと思います)
実装 - Sobel Filterによるリムライト
ついに本題に入ります。
Sobel Filterとは、一次微分を用いて画像の輪郭を強調するためのフィルタ処理です。この処理について詳しく解説すると長くなってしまうので、参考記事を以下に添付します。
ここでは、このSobel Filterで画面の深度バッファの輪郭検出を行い、その輪郭をもとにリムライト表現を行います。
以下にサンプルコードを記述します。
float4 TransformHClipToNormalizedScreenPos(float4 positionCS)
{
float4 o = positionCS * 0.5f;
o.xy = float2(o.x, o.y * _ProjectionParams.x) + o.w;
o.zw = positionCS.zw;
return o / o.w;
}
half SampleOffsetDepth(float3 positionVS, float2 offset)
{
// カメラとの距離やカメラのFOVで見た目上の輪郭の太さが変わらないように、オフセットをViewSpaceで計算する
float3 samplePositionVS = float3(positionVS.xy + offset, positionVS.z);
float4 samplePositionCS = TransformWViewToHClip(samplePositionVS);
float4 samplePositionVP = TransformHClipToNormalizedScreenPos(samplePositionCS);
float offsetDepth = SAMPLE_TEXTURE2D_X(_CameraDepthTexture, sampler_CameraDepthTexture, samplePositionVP).r;
return offsetDepth;
}
half SobelFilter(float3 positionWS, float thickness)
{
float3x3 sobel_x = float3x3(-1, 0, 1, -2, 0, 2, -1, 0, 1);
float3x3 sobel_y = float3x3(-1, -2, -1, 0, 0, 0, 1, 2, 1);
float edgeX = 0;
float edgeY = 0;
float3 positionVS = TransformWorldToView(positionWS);
UNITY_UNROLL
for (int x = -1; x <= 1; x++)
{
UNITY_UNROLL
for (int y = -1; y <= 1; y++)
{
float2 offset = float2(x,y) * thickness;
half depth = SampleOffsetDepth(positionVS, offset);
depth = LinearEyeDepth(depth, _ZBufferParams);
float intensity = depth;
edgeX += intensity * sobel_x[x + 1][y + 1];
edgeY += intensity * sobel_y[x + 1][y + 1];
}
}
// エッジの強度を計算
float edgeStrength = length(float2(edgeX, edgeY));
edgeStrength = step(_RimThreshold, edgeStrength);
return float4(edgeStrength, edgeStrength, edgeStrength, 1);
}
輪郭検出の際に特定方向に対してUVオフセットをかけて深度バッファをサンプリングしますが、単純にスクリーンUVに対してオフセットをかけるだけだとカメラと頂点の距離が変わった場合や、カメラの画角が変化した場合などにリムの太さが変わってしまいます。
そのため、この実装ではView空間での頂点座標をもとにサンプリング点にオフセットをかけることでその問題を回避しています。この実装サンプルを適用した画像が以下のものになります。
オブジェクトの深度の輪郭にライトを乗せているため、均一の太さのライトオブジェクトの外側に乗っていることがわかります。
さらなる工夫
ここまでの実装でもリムライトっぽい表現は行えていると思いますが、さらに工夫を加えてみましょう。
よりライトの方向感を強調するために、輪郭の太さにシェーディング用のHalf Lambertの結果を乗算してみます。
half halfLambert = dot(normalWS, lightDirWS) * 0.5 + 0.5;
...
// Rim (Sobel Filter)
// リムの太さにHalf Lambertを乗算
half rimThickness = _RimThickness * halfLambert * 0.1;
half rim = SobelFilter(IN.positionWS, rimThickness);
color.rgb += rim * _RimColor.rgb;
この実装によって、光が当たっている箇所のライトは太く、当たっていない箇所のライトは細くなるような表現が実現できました。
補足 - Sobel Filterで苦手な表現
Sobel Filterは輪郭を強調するための処理であるため、内側から外側にふわっと強くなっていくようなリムライト表現には向いていません。
以下の画像のような表現を実現する場合はフレネル効果方式のリムライト表現か、より高度な表現を検証する必要があるでしょう。
おわりに
今回はToonシェーダのリムライト表現のためのアプローチを紹介しました。
ToonシェーダをはじめとしたNPR表現は、正解がなく表現の追求しがいがある面白い領域だと思っているので、みなさんもぜひ挑戦してみてください!
この記事が素敵なキャラクターを描くための一助になれば幸いです。
おまけ - サンプルコード全文
Shader "Custom/Toon"
{
Properties
{
_BaseMap ("Texture", 2D) = "white" {}
_Color ("Color", Color) = (1,1,1,1)
_ShadowColor ("Shadow Color", Color) = (0,0,0,1)
_ShadowThreshold ("Shadow Threshold", Range(0, 1)) = 0.5
_OutlineWidth ("Outline Width", Range(0, 10)) = 1
_RimColor("Rim Color", Color) = (1,1,1,1)
_RimThickness ("Rim Thickness", Range(0, 10)) = 1
_RimThreshold ("Rim Threshold", Range(0, 10)) = 2
}
HLSLINCLUDE
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl"
TEXTURE2D(_BaseMap);
SAMPLER(sampler_BaseMap);
float4 _BaseMap_ST;
float4 _BaseMap_TexelSize;
half4 _Color;
half4 _ShadowColor;
half _ShadowThreshold;
half _RimThickness;
half _RimThreshold;
half4 _RimColor;
half _OutlineWidth;
float4 _CameraDepthTexture_TexelSize;
float4 TransformHClipToNormalizedScreenPos(float4 positionCS)
{
float4 o = positionCS * 0.5f;
o.xy = float2(o.x, o.y * _ProjectionParams.x) + o.w;
o.zw = positionCS.zw;
return o / o.w;
}
half SampleOffsetDepth(float3 positionVS, float2 offset)
{
// カメラとの距離やカメラのFOVで見た目上の輪郭の太さが変わらないように、オフセットをViewSpaceで計算する
float3 samplePositionVS = float3(positionVS.xy + offset, positionVS.z);
float4 samplePositionCS = TransformWViewToHClip(samplePositionVS);
float4 samplePositionVP = TransformHClipToNormalizedScreenPos(samplePositionCS);
float offsetDepth = SAMPLE_TEXTURE2D_X(_CameraDepthTexture, sampler_CameraDepthTexture, samplePositionVP).r;
return offsetDepth;
}
half SobelFilter(float3 positionWS, float thickness)
{
float3x3 sobel_x = float3x3(-1, 0, 1, -2, 0, 2, -1, 0, 1);
float3x3 sobel_y = float3x3(-1, -2, -1, 0, 0, 0, 1, 2, 1);
float edgeX = 0;
float edgeY = 0;
float3 positionVS = TransformWorldToView(positionWS);
UNITY_UNROLL
for (int x = -1; x <= 1; x++)
{
UNITY_UNROLL
for (int y = -1; y <= 1; y++)
{
float2 offset = float2(x,y) * thickness;
half depth = SampleOffsetDepth(positionVS, offset);
depth = LinearEyeDepth(depth, _ZBufferParams);
float intensity = depth;
edgeX += intensity * sobel_x[x + 1][y + 1];
edgeY += intensity * sobel_y[x + 1][y + 1];
}
}
// エッジの強度を計算
float edgeStrength = length(float2(edgeX, edgeY));
edgeStrength = step(_RimThreshold, edgeStrength);
return float4(edgeStrength, edgeStrength, edgeStrength, 1);
}
ENDHLSL
SubShader
{
Tags { "RenderType"="Opaque" }
Pass
{
Cull Back
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma target 2.0
struct Attributes
{
float4 positionOS : POSITION;
float3 normalOS : NORMAL;
float2 uv : TEXCOORD0;
};
struct Varyings
{
float4 positionHCS : SV_POSITION;
float2 uv : TEXCOORD0;
float3 normalWS : TEXCOORD1;
float3 viewDirWS : TEXCOORD2;
float2 screenPos : TEXCOORD3;
float3 positionWS : TEXCOORD4;
};
Varyings vert(Attributes IN)
{
Varyings OUT;
VertexPositionInputs positionInputs = GetVertexPositionInputs(IN.positionOS);
OUT.positionHCS = positionInputs.positionCS;
OUT.positionWS = positionInputs.positionWS;
OUT.uv = IN.uv;
VertexNormalInputs normalInputs = GetVertexNormalInputs(IN.normalOS);
OUT.normalWS = normalInputs.normalWS;
OUT.viewDirWS = GetWorldSpaceViewDir(positionInputs.positionWS);
OUT.screenPos = positionInputs.positionNDC.xy / positionInputs.positionNDC.w;
return OUT;
}
half4 frag(Varyings IN) : SV_Target
{
half4 color = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, IN.uv);
Light light = GetMainLight();
half3 normalWS = IN.normalWS;
half3 lightDirWS = light.direction;
// Lambert
half halfLambert = dot(normalWS, lightDirWS) * 0.5 + 0.5;
half intensity = step(_ShadowThreshold, halfLambert);
color.rgb = color.rgb * lerp(_ShadowColor, _Color, intensity);
// Rim (Fresnel)
/*
half fresnel = saturate(dot(normalWS, normalize(IN.viewDirWS)));
half rim = 1.0 - fresnel;
rim = step(_RimThreshold, rim);
*/
// Rim (Sobel Filter)
half rimThickness = _RimThickness * halfLambert * 0.1;
half rim = SobelFilter(IN.positionWS, rimThickness);
color.rgb += rim * _RimColor.rgb;
return color;
}
ENDHLSL
}
Pass
{
Name "Outline"
Tags
{
"LightMode" = "Outline"
}
ZWrite On
Cull Front
HLSLPROGRAM
#pragma target 2.0
#pragma vertex OutlineVertex
#pragma fragment OutlineFragment
struct Attributes
{
float4 positionOS : POSITION;
float3 normalOS : NORMAL;
float2 uv : TEXCOORD0;
};
struct Varyings
{
float4 positionHCS : SV_POSITION;
float2 uv : TEXCOORD0;
};
Varyings OutlineVertex(Attributes IN)
{
Varyings OUT;
float3 positionOS = IN.positionOS.xyz + normalize(IN.normalOS) * _OutlineWidth * 0.001;
VertexPositionInputs positionInputs = GetVertexPositionInputs(positionOS);
OUT.positionHCS = positionInputs.positionCS;
OUT.uv = IN.uv;
return OUT;
}
half4 OutlineFragment(Varyings IN) : SV_Target
{
return half4(0,0,0,1);
}
ENDHLSL
}
Pass
{
Name "DepthOnly"
Tags
{
"LightMode" = "DepthOnly"
}
ZWrite On
ColorMask R
Cull Back
HLSLPROGRAM
#pragma target 2.0
#pragma vertex DepthOnlyVertex
#pragma fragment DepthOnlyFragment
struct Attributes
{
float4 position : POSITION;
float2 texcoord : TEXCOORD0;
};
struct Varyings
{
float4 positionCS : SV_POSITION;
};
Varyings DepthOnlyVertex(Attributes input)
{
Varyings output = (Varyings)0;
output.positionCS = TransformObjectToHClip(input.position.xyz);
return output;
}
half DepthOnlyFragment(Varyings input) : SV_TARGET
{
return input.positionCS.z;
}
ENDHLSL
}
}
}