こんにちはhibitです。この記事は、VRChat Advent Calender 2018の15日目の記事です。
以下のような自作シェーダを作成しました。GitHubリポジトリはここ。
なんとこのシェーダ、49行で書けますので、**いつかシェーダを写経してみたいけど長すぎて……**とか、**あめりか語をみるとめまいが……**とか、そういった方でも大丈夫! 1行ずつ(ただし私にわかる範囲で)時に丁寧に時に雑に解説します。冬休みの工作気分でレッツトライ!
何はともあれソースコード
Shader "Hibit/HibitShader" {
Properties {
_Color ("Color", Color) = (.5,.5,.5,1)
_RimColor ("RimColor", Color) = (.5,.5,.5,1)
_MainTex ("MainTex", 2D) = "white" {}
_NormalMap ("NormalMap", 2D) = "white" {}
_Change ("Change", Range (0,6.28)) = 0
}
SubShader {
Tags { "RenderType"="Opaque" }
LOD 200
Cull Off
CGPROGRAM
#pragma surface surf Custom
#pragma target 3.0
sampler2D _MainTex;
sampler2D _NormalMap;
fixed4 _Color;
fixed4 _RimColor;
float _Change;
struct Input {
float2 uv_MainTex;
float2 uv_NormalMap;
float3 viewDir;
};
fixed4 LightingCustom (SurfaceOutput s, fixed3 lightDir, fixed atten) {
half d = dot(s.Normal, lightDir)*0.2+0.2;
fixed4 c;
c.rgb = s.Albedo.rgb * _LightColor0.rgb * ((_Color-.5)/3+d);
return c;
}
void surf (Input IN, inout SurfaceOutput o) {
fixed4 c = tex2D (_MainTex, IN.uv_MainTex);
half rim = 1 - saturate(dot(IN.viewDir, o.Normal));
fixed3 rot = fixed3(saturate(cos(_Change)),saturate(cos(_Change+UNITY_PI*2/3)),saturate(cos(_Change+UNITY_PI*4/3)));
fixed3 Crgb = fixed3(dot(rot,c.rgb),dot(rot,c.gbr),dot(rot,c.brg));
half3 environment = ShadeSH9(half4(o.Normal, 1));
o.Emission = (2*Crgb+_RimColor)/3 * pow(rim, 3)*environment*2;
o.Albedo = Crgb;
o.Normal = UnpackNormal(tex2D (_NormalMap, IN.uv_NormalMap));
}
ENDCG
}
FallBack "Diffuse"
}
これを各部分ずつ解説していきます。
プロパティ
先頭の行でシェーダのカテゴリとUnity上のシェーダ名を指定します。赤マルで区切った部分ですね。
次のプロパティは大事ですね。ここはUnityのインスペクタでユーザが値をいじったりテクスチャをぶっこんだりする所です。黄色で囲った所の1行が、それぞれUnity上のスライダーやテクスチャに対応する訳です。
つまり、以下の画像のように対応します。
後で詳細な数学的作用を説明しますが、
- Color 調整用の色味
- RimColor リムライト(輪郭光)の色
- MainTex いわゆるテクスチャ画像
- NormalMap ノーマルマップ
- Change 色相の変換具合
を示しています。
プロパティの種類にはRange
(変数、スライダー)、Color
(色、カラーピッカーで選ぶ)2D
(テクスチャ、画像を読み込ませる)などがあります。詳しい種類は公式リファレンスへ。黄色で囲ったクォーテーション内の文字がUnity上で表示されるパラメータ名、左のアンダーバーがついたやつ(_Color
みたいなやつ)が変数名になります。
変数名は別に何でも良いのですが、テクスチャ画像の変数名を_MainTex
にしておくと、VRChat上でシェーダブロックされてもスタンダードシェーダとしてそのテクスチャが表示されるというメリットがあります。
#タグとか
次はサブシェーダを指定します。赤枠の部分は"Opaque"
にします。なぜかというと……よくわからないけど普通のシェーダはOpaqueにしろって公式リファレンスに書いてあるんだもん。ちなみにOpaqueとは「不透明な」という意味です。透過したい時とかは"Transparent"
とか使うらしいですよ。
LOD
というのはLevel of Detailの略で、公式リファレンスによると「カメラとオブジェクトの距離が離れた場合にレンダリングする三角形の数を減らす事ができます」とのことですが、何も考えずに200でいいと思います。
黄枠のCull Off
は結構大事で、この1行を追加すると、シェーダがポリゴンの両面を描画するようになります。デフォルトだと裏面は透過してしまうので、両面を描画したい時は必須のコマンドとなります。
#CGプログラム
この部分はライティングのやり方とかを指定します。赤枠の部分は、デフォルトのサーフェスシェーダだと#pragma surface surf Standard fullforwardshadows
という記述になっています。これはライティングのメソッドをスタンダードライティングでいい感じにやってくれ~ということを示しています。当シェーダではライティングメソッドをいじる必要があるので「いや、そこはオリジナルでやらせてくれよ」という意思表示が必要になります。それが赤枠の部分で、「ライティングはここで指定したメソッドでやるから!」ということになります。コードではCustom
としていますが、別に好みのメソッド名で構いません。
target 3.0
では何をしているかよくわかりません(おい)。でも消すとエラーになるのできっと大事な部分なんでしょう(適当)。pragmaってなんだよ。
#プロパティで指定した変数の定義
ここでは、先程プロパティで指定したパラメータを変数として再定義してます。そんなん自動でやってくれよと思わないでもないですが、やりましょう。テクスチャはsampler2D
で、その他変数はfloat
,fixed
,half
+n
で定義します。floatとかhalfとかは数値の精度を示しています。詳しい解説は公式リファレンスへ(ぶっちゃけ、いちいち悩むより、全部floatでやっても問題ないような気もしますが……)。nの部分は次元数を示しておりまして、float2だと2次元配列、float3だと3次元配列、float2x2(間の文字はエックスであることに注意)だと2かける2の行列になります。ちなみに5次元以上の配列は使えません。なんだその欠陥言語……。
#構造体に使う変数
ここでは構造体に使う変数を指定します。構造体って何だよってことは聞かないでください。構造体に何を入れられるかは公式リファレンスをご覧ください。当シェーダでは、
- テクスチャ画像のUV(uv_MainTex);
- ノーマルマップのUV(uv_NormalMap);
- 視線の方向(viewDir)
の3つが必要なのでそれらを指定しています。
ライティングシェーダ
いよいよ本題に入っていきます。先程、ライティングは独自に書くと言いましたが、その独自メソッドが上になります。赤枠のCustom
は、CGプログラムの段階で書いたCustom
と対応しています。このfixed4{……}
というひとかたまりがひとつの関数を示しており、黄枠で囲った変数を入力して、return
で示された変数を出力という手順になっております。入力変数のSurfaceOutput
はサーフェスシェーダの出力で、これはいわゆるメッシュの各点の色とかの情報を持っています。lightDir
は光の方向、atten
は光の減衰を示しており、ライティングに使える定義済関数です。
図で言うとココの部分をいま書いている訳です。サーフェスシェーダと順番が前後しちゃって申し訳ない。別に後で記述するサーフェスシェーダのvoid{……}
の後に書いてもいいのですが、コードを書くにあたって参考にした諸先輩方のコードが皆最後にサーフェスシェーダを書いてるので、それに倣っています。何か理由があったりするのだろうか。
青枠のhalf d = dot(s.Normal, lightDir)*0.2+0.2
について説明します。これは、光の当たり方によって色の明るさを変える処理を示しています。s.Normal
がメッシュの法線、lightDir
が光の方向で、それらの内積dot
は、光と法線が垂直になる時0、水平になる時1になります。メッシュの法線が光と垂直になるということは、メッシュと光は平行になるということです。回りくどい説明になりましたが、要は光があたらないと0で、よくあたるようになるほど1に近づくということです。後でこれを発色の値にかけることによって「光の当たり具合」=「発色の明るさ」となるように調節しています。例えばUnlitではこのような処理をしてないので、光源に関係なく発色が決まる訳です。
ここで当シェーダの工夫ポイントその1が出てきますが、内積をそのまま使うと光の当たり具合をストレートに発色に示してしまい、いわゆるスタンダードシェーダ特有のツヤツヤ感が出てしまいます。そこで、当シェーダでは、光が当たらなくてもそこまで暗くならないし、光があたってもそこまで明るくならない、という風に調節することにより、スタンダードとアンリットの中間のようなソフトな色合いを出しています。それがdot(略)*0.2 + 0.2
の部分です。グラフにするとこんな感じです。
これから更に、プロパティで指定した_Color
を調整用の色味として足しています。これも生の値を足してしまうと調整を通り越して全部その色になってしまうので、数値が小さくなるような補正を加えています。それらを合わせたのが橙枠の部分になります。日本語と並べるとこんな感じです。
c.rgb = s.Albedo.rgb * _LightColor0.rgb * ((_Color-.5)/3+d);
「最終的な出力 = メッシュの色 × 光の色 × (調整用の色味 + 光のあたり具合)」
これを意識した上で読むと、コードでやってることがわかりやすいのではないでしょうか。Albedoとは入射光と反射光の比らしいですが、サーフェスシェーダでは色のことをなぜかこう呼びます。
サーフェスシェーダ
シェーダの処理的には前後しますが、今度はサーフェスシェーダの処理に写っていきます。量が多いですが頑張って解説していきます。手続き的には以下の図の部分になりますね。
赤枠の部分がインプットとアウトプットの名前を示しています。サーフェス→ライティングの名前、さっきはs
で定義してたじゃん、なんで変えるの? と思った方へ。要は関数の中で整合性が取れていればいいのでs
でもo
でもhoge
でも何でもいいのですが、名前を変えといた方が同じ関数の中で変数が完結してデバッグしやすいとかですかね? 理由は私にもよくわかりません。
次の行のfixed4 c = tex2D (_MainTex, IN.uv_MainTex);
は決まり文句のようなものです。テクスチャ画像から、UVマップに対応したメッシュに着色して、それをc
と定義しています。
黄枠のhalf rim = 1 - saturate(dot(IN.viewDir, o.Normal));
、これは結構大事な行で、リムライトの強さを指定しています。というか、リムライトがリムライトらしく光るよう、縁だけ光るように指定する、といった方が正しいですね。計算は先程でてきたdot(o.Normal, lightDir)
と似ていますが、今度は、メッシュの法線と視線の方向の内積を取っています。ただ、それだけだと視線が当たれば当たる程強く発色するということになってしまい、リムライトで演出したいものとは逆の効果になっています。そこで、1 - saturate(dot(IN.viewDir, o.Normal))
という形で1から引くことによってそれを逆転させています。
また、saturate
という関数が余計についていますが、これは中身を0~1の間に留めるという関数です。これがないと、内積がマイナスになった時(つまり視線の反対側にあるようなメッシュ)が強く発光してしまうという現象が起こってしまうので、それを防ぐためです。
青枠の部分は、プロパティで指定して_Change
の値だけ色相を変える、という機能を実装しています。要は、周期的が3分の1ずつずれた三角関数を用意して、それに**赤緑青をかけたものを「新赤」、緑青赤→「新緑」、青赤緑→「新青」**と定義することにより擬似的な色相変換を実現しています。(ここで言う赤緑青は、テクスチャ画像から取得された色の各成分のことを言います)
あと、ここにもsaturate
が出てきますが、これを噛ませておかないと「青の分の色だけ『新赤』の色がひかれる」みたいな現象が起こってしまうので、それを防ぐためです。というか、要はある周期の3分の1をカバーするような曲線が描ければ何でも良いのですが、このシェーダでは三角関数を使っていて、三角関数そのままだとマイナスにはみ出てしまう部分があるためそれをカットするためにsaturate
を噛ませる、というような発想ですね。
その下のhalf3 environment = ShadeSH9(half4(o.Normal, 1));
も決まり文句のようなものですが、これは環境光を取得しています。後でリムライトの発色を指定するのですが、その時にこれがないと周りは真っ暗で、アバターも真っ黒なのに、リムライトだけは光ってしまうという現象が起こるため、それを防ぐためです。ここはjoniburnさんにフィードバックいただいた上手く改良できた部分です。ありがとうございました!
次の橙枠のはリムライトの発色を指定しています。先に(2*Crgb+_RimColor)/3
の解説をしますが、ここは当シェーダの工夫ポイントその2になります。これはリムライトの色を「テクスチャの元々の色と、_RimColor
で指定した色を混ぜたもの」(正確には2:1の割合で混合)にしています。他のシェーダではリムライトが単一の色になっているものが多いのですが、当シェーダではテクスチャの色をリムライトに利用することにより、よりリムライトをソフトな形で演出しています。シンプルですが、なかなか良いアイディアではないでしょうか(自画自賛)。で、そうして決定された最終的なリムライトの色に、リムライトの強さ、環境光による減数をかけて最終的な出力にしている訳です。これも日本語と並べると、
o.Emission = (2*Crgb+_RimColor)/3 * pow(rim, 3)*environment*2;
「リムライトの光り具合 = (テクスチャ色と調整用の色を混ぜた色) × リムライトの強さ × 環境光による減衰」
という感じになります。なお、リムライトの強さにpow
という関数が噛ませてありますが、これは3乗することにより強さのカーブを変化させているだけです。
次のo.Albedo = Crgb;
は、ライティングシェーダへのアウトプットの色に今まで計算してきた色を渡しますという意味です。
次でやっと最後の行ですね。o.Normal = UnpackNormal(tex2D (_NormalMap, IN.uv_NormalMap));
ですが、これもまた決まり文句です(決まり文句が多いな!)。ノーマルマップ用のテクスチャを指定している場合、この命令によってノーマルマップの凹凸が法線の形として後のライティングに反映されるようになります。詳しくは公式リファレンスへ。
※12/16追記
ワールドのライティングによっては、シームの切れ目でライティングが急に変わってしまうことがあるようです。その場合は、この行を削除すると直ります(ノーマルマップも適用できなくなりますが)。また、それとは別に、削除した方がよりソフトなライティングとなります。この現象はノーマルマップにテクスチャを指定していなくてもなるので、ノーマルマップを使わないなら6,19,26,38行目を最初から削除しておいた方が無難かつシンプルかもしれません。原因は不明です、すいません。GitHubのリポジトリに、以上の変更を施したHibitShader(Non Normal)というバージョンも併せて上げています。
※追記ここまで
最後に
という訳で、49行という短いシェーダではありますが、一応すべての行について解説をつけました。一部不明のままのものや、「決まり文句」で済ませてしまったものもあって申し訳ないですが、皆様のシェーダライフの一助になれば幸いです。しかし疲れた……。
当シェーダを作るにあたって、Unityの公式リファレンスの他、おもちゃラボ様の7日間でマスターするUnityシェーダには大変助けられました。というか、コードの基本的な書き方はすべてこのサイトから学ばせてもらったといっても過言ではないです。また、環境光の取得にはUnity の Shader で環境光を得るの記事を参考にさせていただきました。この場を借りて感謝申し上げます。
私もシェーダについて学び始めた初心者ですので、当記事について曖昧な部分、誤認している部分があれば遠慮なくコメント、編集リクエストを送っていただければと思います。
明日のAdvent Calenderは@jscmla1118さんです。