はじめに
前々からUnityでShaderLabやHLSL/Cgを使用した自作シェーダーの実装を学びたいと思っていたのですが、ただサンプルコードを写経しているだけだとどうしても自分で考える癖がつかず定着しなかったので、すでにある表現をShaderLabで一から再現してみるというアプローチがいいのでは?と思いました。例えば、炎の表現をもってきて、この表現にはどんな技術が使われているのかを考え、それをコードに起こしていくイメージです。ただ、いきなり完成された表現だけをみて再現するというのはハードルが高い気がしました。そこで、(ネットに転がってるサンプルなどを参考に)ShaderGraphで表現を作成して、それをコードに書き下していくという勉強法はなかなかいいのではないかと思い実践してみたので、今回は作成した表現を1つ例にとり、その手順を紹介していこうと思います!
環境
- macOS Catalina 10.15.7
- Unity2019.4.12f1
プロジェクトの作成
シェーダーの実装を初めていく前に、まずUnityのプロジェクトを作成しなくてはなりません。今回使用するShaderGraphは、SRP(Scriptable Render Pipline)が導入された環境が必要になるので、その点に注意してプロジェクトを作成していきます。今回はUnityから提供されているURPが導入されたテンプレートを用いてプロジェクトを作成しました。
ShaderGraphで表現を作ってみる
ありがたいことにShaderGraphのサンプルはネット上にいくらでも転がっています。慣れないうちはそれらの表現を参考に作っていけば良いと思います。今回は、【Unite Tokyo 2018】新機能Shader Graphを使えばプログラミング無しにシェーダーが作れるようになります!の例1で紹介されているディゾルブエフェクトを拝借して、このようなエフェクトを作成しました。
※ 本記事ではSahderGraphの構築部分についての詳細な説明は省きますので、実際に作ってみたいという場合は、「【Unite Tokyo 2018】新機能Shader Graphを使えばプログラミング無しにシェーダーが作れるようになります!」を見てください。
ShaderLabで書き起こしてみる
では実際にSahderGraphを用いて作成したディゾルブエフェクトをShaderLabを用いて再現していきたいと思います!
まずはこの表現を作成するのにどのような要素が必要なのかの洗い出しをしていきます。これは、ShaderGraphでエフェクトを作成した際にどのような手順を経たかを思い出せば見えてきそうですね。今回必要な要素としては、
① オブジェクトの色を変更する
② ノイズの生成
③ アルファカットアウト
④ 閾値を時間の経過にしたがって変化させる
⑤ 消えていく境界線の部分の色を変える
⑥ 陰影付け
あたりだと思うので、順番に実装していきます。
ただその前に、シェーダーを記述するファイルを作成しておきます。今回はUnlit Shaderで実装を進めていきます。
メニューからUnlitShaderのファイルを作成するとすでにコードが書かれていると思います。
Unlit Shaderではこの形式に則り、頂点シェーダーとフラグメントシェーダーを実装していくことになります。
今回はテクスチャやフォグに関する部分は必要ないので消して、このような形にしておきます。
Shader "Unlit/Dissolve"
{
Properties
{
}
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 100
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
};
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
fixed4 col = fixed4(1, 1, 1, 1); // とりあえず白にしておく
return col;
}
ENDCG
}
}
}
ここまで行ったらUnityに戻り、今作成したDissolve.shaderからオブジェクトにアタッチするマテリアルを生成します。シーン上に任意のオブジェクトを作成して、生成したマテリアルをアタッチすると、オブジェクトが白くなったと思います。これでシェーダーを書き進めていく準備は整ったので、要素を順番に実装していきます!
① オブジェクトの色を変更する
まずはとりあえず白くしていたオブジェクトの色を変更していきます。
ShaderGraphでいうと、黄色い枠で囲んだあたりに該当します。
オブジェクトの色を変えるにはコード上で直接特定の色を指定してもいいのですが、そうすると色を微調整したいという要望が生じた際にいちいちコードを変更しなければならないので、今回はUnityのインスペクターから変更できるようにします。インスペクターから色を変更するには、以下のようにShaderLabのPropertiesタグの中でパラメータを設定します。
Properties
{
// パラメータ名 ("表示名", データ形式) = 初期値 という形で設定
_Color ("Color", Color) = (1, 1, 1, 1)
}
そのパラメータをフラグメントシェーダーで参照するという形をとります。
fixed4 _Color;
fixed4 frag (v2f i) : SV_Target
{
fixed4 col = _Color;
return col;
}
こうすることで、オブジェクトのshaderを確認すると、このように色が設定できるようになっています。
② ノイズの生成
次にディゾルブ表現の要であるノイズの生成を行っていきます。
ShaderGraphでは、枠で囲んだ1つのノードで実現しています。
ShaderGraphでは、ノイズを生成するためのノードを1つ追加すればいいだけなのですが、HLSL/Cgにはノイズを生成する関数がないようなので、自作する必要があります。ノイズの生成部分の実装に関しても解説したいところではありますが、少し長くなってしまうのでここでは説明は省きます。実装の際はこちらの記事を参考にさせていただきました。今回はパーリンノイズを採用しました。
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
float random(fixed2 uv, fixed2 size)
{
uv = frac(uv / size);
return frac(sin(dot(uv, fixed2(12.9898, 78.233))) * 43758.5453);
}
fixed2 randVec(fixed2 uv, fixed2 size){
return normalize(fixed2( random(uv, size), random(uv+fixed2(127.1, 311.7), size) ) * 2.0 + -1.0);
}
fixed2 bilinear(fixed f0, fixed f1, fixed f2, fixed f3, fixed fx, fixed fy)
{
return lerp( lerp(f0, f1, fx), lerp(f2, f3, fx), fy );
}
fixed fade(fixed t)
{
return t * t * t * (t * (t * 6.0 - 15.0) + 10.0);
}
fixed2 perlinNoise(fixed2 uv, fixed2 size)
{
fixed2 p = floor(uv * size);
fixed2 f = frac(uv * size);
fixed d00 = dot(randVec(p + fixed2(0, 0), size), f - fixed2(0, 0));
fixed d01 = dot(randVec(p + fixed2(0, 1), size), f - fixed2(0, 1));
fixed d10 = dot(randVec(p + fixed2(1, 0), size), f - fixed2(1, 0));
fixed d11 = dot(randVec(p + fixed2(1, 1), size), f - fixed2(1, 1));
return bilinear(d00, d10, d01, d11, fade(f.x), fade(f.y)) + 0.5f;
}
...
ENDCG
③ アルファカットアウト
今回はアルファ値が特定の値以下のピクセルを描画しないようにすることでオブジェクトが溶けていく表現を実現します。ShaderGraphでは、PBR MasterノードのAlphaClipThresholdに値を設定することで実現しています。
まずは各ピクセルのアルファ値を先ほど実装したパーリンノイズによって求めていきます。
fixed4 frag (v2f i) : SV_Target
{
float alpha = perlinNoise(i.uv, fixed2(10, 10)); //
fixed4 col = _Color;
return col;
}
パーリンノイズを生成する際に各ピクセルのUV座標を引数に渡す必要があるため、フラグメントシェーダーでUV座標を取得できるようにしておきます。
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = v.uv;
return o;
}
次にアルファ値が閾値以下のピクセルの描画をスキップする処理を実装していきます。
HLSL/Cgにおいて、あるピクセルの描画をスキップするには、clip
という関数を使用します。
clip関数の引数に指定した値が0以下のとき、そのピクセルの描画をスキップします。
ここではとりあえず閾値は0.5に設定しておきましょう。
fixed4 frag (v2f i) : SV_Target
{
float alpha = perlinNoise(i.uv, fixed2(10, 10));
float threshold = 0.5f;
clip(alpha - threshold);
fixed4 col = _Color;
return col;
}
こうすることで、パーリンノイズによって求めた値が0.5以下のピクセルが描画されなくなります。
徐々に完成形に近づいてきましたね!
④ 閾値を時間によって変化させる
先ほどはとりあえず閾値を0.5に設定していましたが、徐々に溶けていく表現を実現するために、閾値を時間経過に伴い変化させていきます。ShaderGraphではこの辺りです。
ShaderLabで時間の概念を導入する場合は、_TimeというUnityの組み込み変数を使用します。_Timeはプログラムが開始されてからの経過時間を表します。_Timeの型はfloat4なので、4つの値を持っているということになります。注意しなくてはいけないのは、その4つの値が順番に(t/20, t, t2, t3)となっている点です(tは実時間、単位は秒)。1つ目の要素を用いると、実時間の20分の1の時間の流れになってしまうので、実時間を参照する場合は2つ目の要素を用います。
今回は、溶ける表現が繰り返し再生されるように、fracメソッドを用いて経過時間の小数点以下のみを取得します。
fixed4 frag (v2f i) : SV_Target
{
float alpha = perlinNoise(i.uv, fixed2(10, 10));
float coefficient = 0.5; // 速度を調整するための
float threshold = frac(_Time.y * coefficient);
clip(alpha - threshold);
fixed4 col = _Color;
return col;
}
⑤ 消えていく境界線の部分の色を変える
ShaderGraphで作成した表現をみると、消えていく際に境界線の部分が白く光っているのがわかると思います。
次はこれを再現していきます。ShaderGraphではこの辺りです。
ShaderGraphではHDRを使っていますが、ここでは単純に白色に変えるに留めたいと思います。
境界の色を変える方法は割と単純で、thresholdの値よりも少し大きな値を設定し、その値よりもアルファ値が小さなピクセルの色を変えます。もともとthreshold以下の領域は描画されないようになっているため、その間の微小領域のみ色が変わることになります。
コードに起こすとこんな感じです。
fixed4 frag (v2f i) : SV_Target
{
float alpha = perlinNoise(i.uv, fixed2(10, 10));
float coefficient = 0.5;
float threshold = frac(_Time.y * coefficient);
clip(alpha - threshold);
fixed4 color;
float delta = 0.05f; // 微小値を0.05に設定
if (alpha < threshold + delta)
{
color = fixed4(1, 1, 1, 1);
}
else
{
color = _Color;
}
return color;
}
実際に境界線部分が白くなっていることが確認できました!
⑥ 陰影付けを行う
⑤までの工程で、ShaderGraphでエフェクトを作成する際の手順全てなぞり終わったのですが、完成形と見比べるとまだなにか物足りなさを感じると思います。現状だと、オブジェクトに陰影付けが施されておらず、いまいちオブジェクトが立体的に見えません!
ShaderGraphでエフェクトを作成する際には、実はPBR Graphというテンプレートを用いてエフェクトの作成を開始しました。そのため特にノードを追加せずとも最初からライトの影響が考慮されていたというわけです。ですが、ShaderLabの方ではUnlitShaderを用いてエフェクトの実装を開始したため、デフォルトではライトの影響は結果に反映されていません。そのため、UnlitShaderでは陰影付けの部分も自分で実装していく必要があります。
PBRを一から再現するのは難易度が高すぎると思うので、ここでは単純にランバート反射モデルを採用します。ランバート反射モデルとは、入射光と各ポリゴンの法線ベクトルの内積によって、物体表面の明るさを求めていく方法になります。詳しい解説はwikipediaに任せます笑
計算過程でポリゴンの法線ベクトルを用いるため、頂点シェーダーとフラグメントシェーダーで法線ベクトルを参照できるようにしていきます。
// プログラムから頂点シェーダーに渡される構造体
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
float3 normal : NORMAL;
};
// 頂点シェーダーからフラグメントシェーダーに渡される構造体
struct v2f
{
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
float3 normal : TEXCOORD1;
};
また、頂点シェーダーに渡される法線ベクトルはローカル座標系での値なので、頂点シェーダーでワールド座標系への変換してフラグメントシェーダーに渡していきます。
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.normal = normalize(UnityObjectToWorldNormal(v.normal));
o.uv = v.uv;
return o;
}
これで、フラグメントシェーダーで法線ベクトルを扱えるようになったので、実際にランバート反射モデルを適用して、陰影付けを行っていきます。コードはこちらになります。
fixed4 frag (v2f i) : SV_Target
{
float alpha = perlinNoise(i.uv, fixed2(10, 10));
float coefficient = 0.5;
float threshold = frac(_Time.y * coefficient);
clip(alpha - threshold);
fixed4 color;
float delta = 0.05f; // 微小値を0.05に設定
if (alpha < threshold + delta)
{
color = fixed4(1, 1, 1, 1);
}
else
{
float intensity = 5.0f; // 明るさに関する補正値
color = _Color * max(0, dot(i.normal, normalize(_WorldSpaceLightPos0.xyz))) * intensity;
}
return color;
}
ShaderLabでは_WorldSpaceLightPos0
という組み込み変数を用いてシーン上のライトの方向を取得できます。float4で定義されていますが、4つ目の要素には0が入っているので、3つ目までの要素を使用します。頂点シェーダーから受け取ったワールド座標での正規化法線ベクトルとシーン上のライトの方向を表すベクトルを正規化したものの内積を求め、それを補正値と共にもとの色に掛け合わせていくというのを各ピクセルに対して行います。(色は負の値になってはいけないので、内積値が0を下回った場合は0にするという点に注意!)
ShaderGraphとShaderLabの表現を比較してみる
以上で最初に書き出した要素は全て実装が完了しました!
実際にShaderGraphで作成したエフェクトとShaderLabで実装したエフェクトを比較してみましょう。
簡単のために単純な手法に置き換えている箇所があるため厳密な再現とはいきませんでしたが、かなり近づけることは出来たかと思います!一応ShaderLabのコード全文を載せておきます。
Shader "Unlit/Dissolve"
{
Properties
{
// パラメータ名 ("表示名", データ形式) = 初期値 という形で設定
_Color ("Color", Color) = (1, 1, 1, 1)
}
SubShader
{
Tags { "RenderType"="Transparent" }
LOD 100
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
float random(fixed2 uv, fixed2 size)
{
uv = frac(uv / size);
return frac(sin(dot(uv, fixed2(12.9898, 78.233))) * 43758.5453);
}
fixed2 randVec(fixed2 uv, fixed2 size){
return normalize(fixed2( random(uv, size), random(uv+fixed2(127.1, 311.7), size) ) * 2.0 + -1.0);
}
fixed2 bilinear(fixed f0, fixed f1, fixed f2, fixed f3, fixed fx, fixed fy)
{
return lerp( lerp(f0, f1, fx), lerp(f2, f3, fx), fy );
}
fixed fade(fixed t) { return t * t * t * (t * (t * 6.0 - 15.0) + 10.0); }
fixed2 perlinNoise(fixed2 uv, fixed2 size)
{
fixed2 p = floor(uv * size);
fixed2 f = frac(uv * size);
fixed d00 = dot(randVec(p + fixed2(0, 0), size), f - fixed2(0, 0));
fixed d01 = dot(randVec(p + fixed2(0, 1), size), f - fixed2(0, 1));
fixed d10 = dot(randVec(p + fixed2(1, 0), size), f - fixed2(1, 0));
fixed d11 = dot(randVec(p + fixed2(1, 1), size), f - fixed2(1, 1));
return bilinear(d00, d10, d01, d11, fade(f.x), fade(f.y)) + 0.5f;
}
// プログラムから頂点シェーダーに渡される構造体
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
float3 normal : NORMAL;
};
// 頂点シェーダーからフラグメントシェーダーに渡される構造体
struct v2f
{
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
float3 normal : TEXCOORD1;
};
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.normal = normalize(UnityObjectToWorldNormal(v.normal));
o.uv = v.uv;
return o;
}
fixed4 _Color;
fixed4 frag (v2f i) : SV_Target
{
float alpha = perlinNoise(i.uv, fixed2(10, 10));
float coefficient = 0.5;
float threshold = frac(_Time.y * coefficient);
clip(alpha - threshold);
fixed4 color;
float delta = 0.05f; // 微小値を0.05に設定
if (alpha < threshold + delta)
{
color = fixed4(1, 1, 1, 1);
}
else
{
float intensity = 2.0f; // 明るさに関する補正値
color = _Color * max(0, dot(i.normal, normalize(_WorldSpaceLightPos0.xyz))) * intensity;
}
return color;
}
ENDCG
}
}
}
最後に
今回はそこまで複雑ではない表現を取り上げましたが、それでもシェーダーに関する数多くの知見が得られたと思います。(インスペクターから値を変更する方法、ノイズの生成、アルファカット、時間変化の導入、シェーディング、などなど ...)ShaderGraphで表現を作成する過程で手順は頭に入っているのでコードにも書き起こしやすいですし、ShaderGraphとShaderLabを同時に学べてお得です!笑 もしUnityでのシェーダーの勉強にお困りの方がいらっしゃれば、是非一度試してみてはいかがでしょうか?
最後になりますが、この記事を読んで「面白かった」「学びがあった」と思っていただけた方、よろしければ Twitter や facebook、はてなブックマークにてコメントをお願いします!また DeNA 公式 Twitter アカウント @DeNAxTech では、 Blog記事だけでなく色々な勉強会での登壇資料も発信してます。ぜひフォローして下さい!