ゲーム開発
Unity
UnrealEngine
C++
Blender
ゲーム数学
ゲームAI
グラフィックス
サウンド
アニメーション
GBDK
制作日記
IT関連
ツール開発
フロントエンド関連
サーバサイド関連
WordPress関連
ソフトウェア設計
おすすめ技術書
音楽
DTM
楽器・機材
ピアノ
ラーメン日記
四コマ漫画
その他
おすすめアイテム
おもしろコラム
  • ゲーム開発
    • Unity
    • UnrealEngine
    • C++
    • Blender
    • ゲーム数学
    • ゲームAI
    • グラフィックス
    • サウンド
    • アニメーション
    • GBDK
    • 制作日記
  • IT関連
    • ツール開発
    • フロントエンド関連
    • サーバサイド関連
    • WordPress関連
    • ソフトウェア設計
    • おすすめ技術書
  • 音楽
    • DTM
    • 楽器・機材
    • ピアノ
  • ラーメン日記
    • 四コマ漫画
      • その他
        • おすすめアイテム
        • おもしろコラム
      1. ホーム
      2. 20230101_02

      【Unity】トゥーンシェーダーを一から自作する(陰影+輪郭線)【シェーダー】

      Unityグラフィックスシェーダー
      2023-01-02

      マイケル
      マイケル
      みなさんこんにちは!
      マイケルです!
      エレキベア
      エレキベア
      こんにちクマ〜〜〜
      マイケル
      マイケル
      新年一発目の記事は、みんな大好きトゥーンシェーダーの実装だ!
      エレキベア
      エレキベア
      ついに来たクマか・・・
      マイケル
      マイケル
      トゥーン調陰影から、輪郭線描画まで行って、
      更にその改善まで行うので本格的な内容になっています!
      エレキベア
      エレキベア
      それは楽しみクマ〜〜〜

      トゥーン調の陰影の実装

      マイケル
      マイケル
      まずはトゥーン調の陰影の実装から!
      今回は
      ・コード内で設定する方法
      ・ランプテクスチャで設定する方法
      の2つを解説します!

      コード内で設定する方法

      マイケル
      マイケル
      コード内で陰影を設定する方法として、法線と光の方向の内積の値からしきい値を設ける方法があります。
      実装内容は下記になります。
      Shader "Unlit/ToonSimpleShader"
      {
          Properties
          {
              _MainTex ("Texture", 2D) = "white" {}
          }
          SubShader
          {
              Tags { "RenderType"="Opaque" }
              LOD 100
      
              Pass
              {
                  CGPROGRAM
                  #pragma vertex vert
                  #pragma fragment frag
      
                  #include "UnityCG.cginc"
      
                  struct appdata
                  {
                      float4 vertex : POSITION;
                      float3 normal : NORMAL;
                      float2 uv : TEXCOORD0;
                  };
      
                  struct v2f
                  {
                      float4 vertex : SV_POSITION;
                      float3 normal : NORMAL;
                      float2 uv : TEXCOORD0;
                  };
      
                  sampler2D _MainTex;
                  float4 _MainTex_ST;
      
                  v2f vert (appdata v)
                  {
                      v2f o;
                      o.vertex = UnityObjectToClipPos(v.vertex);
                      o.normal = UnityObjectToWorldNormal(v.normal);
                      o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                      return o;
                  }
      
                  fixed4 frag (v2f i) : SV_Target
                  {
                      // しきい値を設けて陰影を計算
                      half nl = max(0, dot(i.normal, _WorldSpaceLightPos0.xyz));
                      if (nl <= 0.01f) nl = 0.3f;
                      else if (nl <= 0.3f) nl = 0.5f;
                      else nl = 1.0f;
                      // テクスチャカラーに乗算
                      fixed4 col = tex2D(_MainTex, i.uv);
                      col.rgb *= nl;
                      return col;
                  }
                  ENDCG
              }
          }
      }
      マイケル
      マイケル
      こちらはオモチャラボさんの下記の記事を参考にさせていただいています!
      輪郭線の実装もこちらの記事を見させていただいています!

      【Unityシェーダ入門】アウトラインシェーダを作る | おもちゃラボ

      マイケル
      マイケル
      陰影の実装部分は下記になっています。
                  fixed4 frag (v2f i) : SV_Target
                  {
                      // しきい値を設けて陰影を計算
                      half nl = max(0, dot(i.normal, _WorldSpaceLightPos0.xyz));
                      if (nl <= 0.01f) nl = 0.3f;
                      else if (nl <= 0.3f) nl = 0.5f;
                      else nl = 1.0f;
                      // テクスチャカラーに乗算
                      fixed4 col = tex2D(_MainTex, i.uv);
                      col.rgb *= nl;
                      return col;
                  }
                  ENDCG
              }
      ↑内積の値からしきい値を設ける
      エレキベア
      エレキベア
      シンプルな実装クマね
      マイケル
      マイケル
      こちらの実行結果は下記になります。
      問題なく陰影が付き、光の方向によって変わるのも確認できます。
      エレキベア
      エレキベア
      おお〜〜〜トゥーンっぽいクマ〜〜

      ランプテクスチャで設定する方法

      マイケル
      マイケル
      前述の内容でもコードを書き換えれば陰影も変えることができますが、
      ランプテクスチャとして用意して参照することで、テクスチャを変えるだけで陰影を変えることができるようになります。
      ↑ランプテクスチャの例
      エレキベア
      エレキベア
      このテクスチャの色を陰影とするクマね
      マイケル
      マイケル
      具体的な実装は下記になります。
      Shader "Unlit/ToonRampShader"
      {
          Properties
          {
              _MainTex ("Texture", 2D) = "white" {}
              _RampTex ("Ramp", 2D) = "white" {}
          }
          SubShader
          {
              Tags { "RenderType"="Opaque" }
              LOD 100
      
              Pass
              {
                  CGPROGRAM
                  #pragma vertex vert
                  #pragma fragment frag
      
                  #include "UnityCG.cginc"
      
                  struct appdata
                  {
                      float4 vertex : POSITION;
                      float3 normal : NORMAL;
                      float2 uv_MainTex : TEXCOORD0;
                      float2 uv_RampTex : TEXCOORD1;
                  };
      
                  struct v2f
                  {
                      float4 vertex : SV_POSITION;
                      float3 normal : NORMAL;
                      float2 uv_MainTex : TEXCOORD0;
                      float2 uv_RampTex : TEXCOORD1;
                  };
      
                  sampler2D _MainTex;
                  float4 _MainTex_ST;
                  sampler2D _RampTex;
                  float4 _RampTex_ST;
      
                  v2f vert (appdata v)
                  {
                      v2f o;
                      o.vertex = UnityObjectToClipPos(v.vertex);
                      o.normal = mul(unity_ObjectToWorld, v.normal);
                      o.uv_MainTex = TRANSFORM_TEX(v.uv_MainTex, _MainTex);
                      o.uv_RampTex = TRANSFORM_TEX(v.uv_RampTex, _RampTex);
                      return o;
                  }
      
                  fixed4 frag (v2f i) : SV_Target
                  {
                      // ライト向き、法線の内積を0.0〜1.0にする
                      const half nl = dot(i.normal, _WorldSpaceLightPos0.xyz) * 0.5 + 0.5;
                      // RampMapから取り出して乗算
                      const fixed3 ramp = tex2D(_RampTex, fixed2(nl, 0.5)).rgb;
                      fixed4 col = tex2D(_MainTex, i.uv_MainTex);
                      col.rgb *= ramp;
                      return col;
                  }
                  ENDCG
              }
          }
      }
      マイケル
      マイケル
      陰影の描画部分は下記!
      ランプテクスチャの縦方向の中心と、内積の結果を座標として参照するようにしています。
                  fixed4 frag (v2f i) : SV_Target
                  {
                      // ライト向き、法線の内積を0.0〜1.0にする
                      const half nl = dot(i.normal, _WorldSpaceLightPos0.xyz) * 0.5 + 0.5;
                      // RampMapから取り出して乗算
                      const fixed3 ramp = tex2D(_RampTex, fixed2(nl, 0.5)).rgb;
                      fixed4 col = tex2D(_MainTex, i.uv_MainTex);
                      col.rgb *= ramp;
                      return col;
                  }
                  ENDCG
              }
      エレキベア
      エレキベア
      さっきのコードで書いてた部分をテクスチャ参照にしたわけクマね
      マイケル
      マイケル
      こちらの実行結果は下記!
      エレキベア
      エレキベア
      こっちもいい感じクマね
      マイケル
      マイケル
      試しに下記のようなテクスチャに変えてみると、陰影結果も変わることが確認できます!
      エレキベア
      エレキベア
      緑っぽくなったクマ〜〜〜

      輪郭線の実装

      背面法を使用する方法

      マイケル
      マイケル
      陰影ができたところで、次は輪郭線を実装します。
      こちらは下記の記事でも少し紹介した、「背面法」という手法を使用した実装になります。
      エレキベア
      エレキベア
      確か、モデルを膨脹して裏面を通常モデルの後ろに描画することで輪郭線っぽく見せる手法だったクマね
      ↑背面法のイメージ
      マイケル
      マイケル
      その通り!
      2回描画する必要があるから、パスも2つ分必要になってくるよ!
      実装は下記になります!
      Shader "Unlit/ToonOutlineShader"
      {
          Properties
          {
              _MainTex ("Texture", 2D) = "white" {}
              _RampTex ("Ramp", 2D) = "white" {}
              _OutlineWidth("Outline Width", Float) = 0.04
          }
          SubShader
          {
              Tags { "RenderType"="Opaque" }
              LOD 100
      
              // 背面
              Pass
              {
                  Cull Front
      
                  CGPROGRAM
                  #pragma vertex vert
                  #pragma fragment frag
      
                  #include "UnityCG.cginc"
      
                  struct appdata
                  {
                      float4 vertex : POSITION;
                      float3 normal : NORMAL;
                  };
      
                  struct v2f
                  {
                      float4 vertex : SV_POSITION;
                  };
      
                  float _OutlineWidth;
      
                  v2f vert (appdata v)
                  {
                      v2f o;
                      v.vertex += float4(v.normal * _OutlineWidth, 0); // 法線方向に膨張
                      o.vertex = UnityObjectToClipPos(v.vertex);
                      return o;
                  }
      
                  fixed4 frag (v2f i) : SV_Target
                  {
                      return fixed4(0, 0, 0, 1); // 黒色で固定
                  }
                  ENDCG
              }
      
              // 前面
              Pass
              {
                  Cull Back
      
                  CGPROGRAM
                  #pragma vertex vert
                  #pragma fragment frag
      
                  #include "UnityCG.cginc"
      
                  struct appdata
                  {
                      float4 vertex : POSITION;
                      float3 normal : NORMAL;
                      float2 uv_MainTex : TEXCOORD0;
                      float2 uv_RampTex : TEXCOORD1;
                  };
      
                  struct v2f
                  {
                      float4 vertex : SV_POSITION;
                      float3 normal : NORMAL;
                      float2 uv_MainTex : TEXCOORD0;
                      float2 uv_RampTex : TEXCOORD1;
                  };
      
                  sampler2D _MainTex;
                  float4 _MainTex_ST;
                  sampler2D _RampTex;
                  float4 _RampTex_ST;
      
                  v2f vert (appdata v)
                  {
                      v2f o;
                      o.vertex = UnityObjectToClipPos(v.vertex);
                      o.normal = UnityObjectToWorldNormal(v.normal);
                      o.uv_MainTex = TRANSFORM_TEX(v.uv_MainTex, _MainTex);
                      o.uv_RampTex = TRANSFORM_TEX(v.uv_RampTex, _RampTex);
                      return o;
                  }
      
                  fixed4 frag (v2f i) : SV_Target
                  {
                      // RampMapから取り出して乗算
                      const half nl = dot(i.normal, _WorldSpaceLightPos0.xyz) * 0.5 + 0.5;
                      const fixed3 ramp = tex2D(_RampTex, fixed2(nl, 0.5)).rgb;
                      fixed4 col = tex2D(_MainTex, i.uv_MainTex);
                      col.rgb *= ramp;
                      return col;
                  }
                  ENDCG
              }
          }
      }
      マイケル
      マイケル
      アウトライン描画部分は下記のパスで、「Cull Front」指定することで裏面を描画するようにしています。
          SubShader
          {
              Tags { "RenderType"="Opaque" }
              LOD 100
      
              // 背面
              Pass
              {
                  Cull Front
      
                  CGPROGRAM
                  #pragma vertex vert
                  #pragma fragment frag
      
                  #include "UnityCG.cginc"
      
                  struct appdata
                  {
                      float4 vertex : POSITION;
                      float3 normal : NORMAL;
                  };
      
                  struct v2f
                  {
                      float4 vertex : SV_POSITION;
                  };
      
                  float _OutlineWidth;
      
                  v2f vert (appdata v)
                  {
                      v2f o;
                      v.vertex += float4(v.normal * _OutlineWidth, 0); // 法線方向に膨張
                      o.vertex = UnityObjectToClipPos(v.vertex);
                      return o;
                  }
      
                  fixed4 frag (v2f i) : SV_Target
                  {
                      return fixed4(0, 0, 0, 1); // 黒色で固定
                  }
                  ENDCG
              }
      エレキベア
      エレキベア
      頂点座標は法縁方向に膨脹させているクマね
      マイケル
      マイケル
      こちらの描画結果は下記になります!
      ↑背面法の描画結果
      エレキベア
      エレキベア
      ちゃんと輪郭線クマ〜〜〜
      でも、キューブの方が何か変になってるクマね
      マイケル
      マイケル
      そう、背面法の欠点として、ハードエッジモデルの輪郭描画がおかしくなってしまうという問題があるんだ。
      こちらの対処法はこの後解説します!
      ハードエッジモデルへの対応
      マイケル
      マイケル
      ハードエッジモデルの輪郭線を綺麗に描画する方法として、
      輪郭線描画の時だけソフトエッジの法線情報を参照するという方法があります。
      マイケル
      マイケル
      そのため、モデルの情報としてソフトエッジの法線情報を別で持たせる方法がありますが、
      今回は下記の記事で紹介されている「Unityスクリプトで法線情報をソフトエッジ情報に変換して頂点カラーに埋め込む」方法で実装してみます!

      輪郭線を少しだけ綺麗に描画する | シンソフィアではたらく人のブログ

      マイケル
      マイケル
      実装は下記になります!
      同じ位置の頂点の法線座標を平均したものをそれぞれ設定する、といった内容になっています。
      using UnityEngine;
      
      public class HardEdgeModel : MonoBehaviour
      {
          private void Awake()
          {
              EmbedSoftEdgeToVertexColor(gameObject);
          }
      
          /// <summary>
          /// ソフトエッジ情報を頂点カラーに埋め込む
          /// </summary>
          /// <param name="obj"></param>
          private static void EmbedSoftEdgeToVertexColor(GameObject obj)
          {
              var meshFilters= obj.GetComponentsInChildren<MeshFilter>();
              foreach (var meshFilter in meshFilters)
              {
                  var mesh = meshFilter.sharedMesh;
                  var normals = mesh.normals;
                  var vertices = mesh.vertices;
                  var vertexCount = mesh.vertexCount;
      
                  // ソフトエッジ法線情報の生成
                  var softEdges = new Color[normals.Length];
                  for (var i = 0; i < vertexCount; i++)
                  {
                      // 同じ位置の頂点の法線座標の平均を設定する
                      var softEdge = Vector3.zero;
                      for (var j = 0; j < vertexCount; j++)
                      {
                          var v = vertices[i] - vertices[j];
                          if (v.sqrMagnitude < 1e-8f)
                          {
                              softEdge += normals[j];
                          }
                      }
                      softEdge.Normalize();
                      softEdges[i] = new Color(softEdge.x, softEdge.y, softEdge.z, 0);
                  }
      
                  // 頂点カラーに埋め込む
                  mesh.colors = softEdges;
              }
          }
      }
      エレキベア
      エレキベア
      なるほどクマ〜〜〜
      ソフトエッジのモデルはそういう意味だったのクマね
      マイケル
      マイケル
      あとは下記のように、頂点を膨脹する際に埋め込んだ頂点カラーを参照するようにすれば完成です!
      Shader "Unlit/ToonOutlineHardShader"
      {
          Properties
          {
              _MainTex ("Texture", 2D) = "white" {}
              _RampTex ("Ramp", 2D) = "white" {}
              _OutlineWidth("Outline Width", Float) = 0.04
          }
          SubShader
          {
              Tags { "RenderType"="Opaque" }
              LOD 100
      
              // 背面
              Pass
              {
                  Cull Front
      
                  CGPROGRAM
                  #pragma vertex vert
                  #pragma fragment frag
      
                  #include "UnityCG.cginc"
      
                  struct appdata
                  {
                      float4 vertex : POSITION;
                      float3 normal : NORMAL;
                      float4 color : COLOR;
                  };
      
                  struct v2f
                  {
                      float4 vertex : SV_POSITION;
                  };
      
                  float _OutlineWidth;
      
                  v2f vert (appdata v)
                  {
                      v2f o;
                      v.vertex += float4(v.color.rgb * _OutlineWidth, 0); // ソフトエッジ法線情報を使用する
                      o.vertex = UnityObjectToClipPos(v.vertex);
                      return o;
                  }
      
                  fixed4 frag (v2f i) : SV_Target
                  {
                      return fixed4(0, 0, 0, 1); // 黒色で固定
                  }
                  ENDCG
              }
      
              // 前面
      
      ・・・同様のため省略・・・
      
          }
      }
      マイケル
      マイケル
      こちらの描画結果は下記になります!
      エレキベア
      エレキベア
      おお〜〜〜〜
      ハードエッジのモデルも綺麗に描画されてるクマ〜〜〜
      アウトラインのマスク対応
      マイケル
      マイケル
      これで大体綺麗に描画できるようになったのですが、
      それでも下記のようにどうしても汚く描画されてしまうことがあります。
      エレキベア
      エレキベア
      うええぇ・・・・
      マイケル
      マイケル
      特にキャラクターの目元が顕著に出てきますね・・・
      こちらの対処法として、輪郭線のアルファ値を記録したテクスチャを用意して参照することで、不要な部分は輪郭線描画しないといった手法があります。
      ↑アルファテクスチャの例(黒色部分は輪郭線描画しない)
      エレキベア
      エレキベア
      少々無理やりクマが、仕方ないクマね・・・
      マイケル
      マイケル
      こちらの実装は下記の通り!
      法線膨脹時にテクスチャをalpha値として参照して掛け合わせるだけですね
      Shader "Unlit/ToonOutlineAlphaShader"
      {
          Properties
          {
              _MainTex ("Texture", 2D) = "white" {}
              _RampTex ("Ramp", 2D) = "white" {}
              _AlphaTex ("Alpha", 2D) = "white" {}
              _OutlineWidth("Outline Width", Float) = 0.04
          }
          SubShader
          {
              Tags { "RenderType"="Opaque" }
              LOD 100
      
              // 背面
              Pass
              {
                  Cull Front
      
                  CGPROGRAM
                  #pragma vertex vert
                  #pragma fragment frag
      
                  #include "UnityCG.cginc"
      
                  struct appdata
                  {
                      float4 vertex : POSITION;
                      float3 normal : NORMAL;
                      float4 color : COLOR;
                      float2 uv_AlphaTex : TEXCOORD2;
                  };
      
                  struct v2f
                  {
                      float4 vertex : SV_POSITION;
                  };
      
                  sampler2D _AlphaTex;
                  float4 _AlphaTex_ST;
                  float _OutlineWidth;
      
                  v2f vert (appdata v)
                  {
                      v2f o;
                      float alpha = tex2Dlod(_AlphaTex, float4(v.uv_AlphaTex.xy, 0, 0)).r; // テクスチャからアルファ値を取得
                      v.vertex += float4(v.color.rgb * (_OutlineWidth * alpha.x), 0);      // ソフトエッジ法線情報を使用する
                      o.vertex = UnityObjectToClipPos(v.vertex);
                      return o;
                  }
      
                  fixed4 frag (v2f i) : SV_Target
                  {
                      return fixed4(0, 0, 0, 1); // 黒色で固定
                  }
                  ENDCG
              }
      
              // 前面
      
      ・・・同様のため省略・・・
      
          }
      }
      エレキベア
      エレキベア
      ちょいと味付けしたくらいクマね
      マイケル
      マイケル
      アルファテクスチャを適当に設定して描画した結果は下記になります!
      エレキベア
      エレキベア
      おお〜〜〜綺麗になったクマ〜〜〜
      マイケル
      マイケル
      まだ少し汚い部分はあるけど、微調整すればよさそうだね!

      おわりに

      マイケル
      マイケル
      というわけで今回はトゥーンシェーダーの実装でした!
      どうだったかな??
      エレキベア
      エレキベア
      案外簡単だったクマが、輪郭線を綺麗に描画するのが中々面倒クマね
      マイケル
      マイケル
      輪郭線描画は、万能な方法が見つかってないと言われるから一工夫必要だね・・・
      ポストエフェクトで輪郭線描画する方法もあるみたいだから、その内実装してみたいね!
      マイケル
      マイケル
      それでは今日はこの辺で!
      アデューー!!
      エレキベア
      エレキベア
      クマ〜〜〜〜

      【Unity】トゥーンシェーダーを一から自作する(陰影+輪郭線)【シェーダー】〜完〜


      Unityグラフィックスシェーダー
      2023-01-02

      関連記事
      【Unity】Timeline × Excelでスライドショーを効率よく制作する
      2024-10-31
      【書籍紹介】「コンピュータグラフィックス」に出てくる用語をまとめる【CGエンジニア検定】
      2024-07-13
      【UE5】Niagara SimulationStageによるシミュレーション環境構築
      2024-05-30
      【Unity】Boidsアルゴリズムを用いて魚の群集シミュレーションを実装する
      2024-05-28
      【Unity】GoでのランキングAPI実装とVPSへのデプロイ方法についてまとめる【Go言語】
      2024-04-14
      【Unity】第二回 Wwiseを使用したサウンド制御 〜インタラクティブミュージック編〜
      2024-03-30
      【Unity】第一回 Wwiseを使用したサウンド制御 〜基本動作編〜
      2024-03-30
      【Unity】第二回 CRI ADXを使用したサウンド制御 〜インタラクティブミュージック編〜
      2024-03-28