本日、ぱふもどきさんがスクラッチ処理を実装している作業実況でシェーダについてコメントさせて頂いたのですが、
やはりYoutubeコメントでは説明が難しく「記事化してもっとわかりやすく説明できないかな?できれば初学者にも伝わりやすい様な…」と思ったので記載してみます。
#スクラッチ処理を実装するにあたって
実際のスクラッチを思い描くとわかりやすいかと思いますが、スクラッチ表現を実装するためには3つの要素が必要となります
- スクラッチの下に隠れている絵(仮にBaseと呼びます)
- スクラッチがどれくらい削られているか、の具合
- スクラッチを削る前に表示されている絵(銀色部分。仮にOverlayと呼びます)
その上で、削られていない部分にOverlayの画像を表示し、削られている部分にはBaseの画像を表示すると実装が完了できます
Unityの機能に落とし込んでみると、何が必要?
上記3点の機能をデータで再現するならば、言ってしまえば最低限2枚のテクスチャが有れば実現は可能です
- Baseテクスチャ
- Overlayテクスチャ(RGBに画像情報、Alpha値に透過度(もともとの透過情報と削られ具合))
この2枚が有り、Overlayテクスチャのアルファ値を操作(削った部分だけ0に)することが出来れば、
実はシェーダを準備しなくても2枚の画像を重ねる、いわゆる**「重ね合わせ」**で実装可能だったりします。
ただ、ぱふもどきさんの実況の際に参考にされていた記事
に出来るだけ合わせてデータを用意しますので、今回は
- Baseテクスチャ(下地の絵)
- Patternテクスチャ(削られ情報)
- Overlayテクスチャ(スクラッチで削られる絵)
の3枚を用いて、シェーダ上で合成して実装する方法で進めます。
#シェーダでスクラッチ処理を実装する利点とは
さて、実装に進む前に、
実況でも挙がった疑問**「なぜシェーダで合成するのか?利点はあるのか?」**という点について、
実況では「オブジェクトやマテリアルを1つにまとめられるので管理しやすい」とコメントしましたが、実はそれ以外にも利点は有ります
複雑な合成方法を実現できる
先に上げた「重ね合わせ」の場合は、主にBland命令で合成を行います。アルファブレンドや加算合成などですね。
正直、シンプルな表現で済むならアルファブレンドの「重ね合わせ」で問題ないでしょう。
ただ、そうすると複雑な模様や形状を表現する上では難しくなってしまいます。
後述する「境目に縁取り線を表示する」など自由な表現や合成を行うためにはシェーダ上で合成する事が利点となります。
##「オブジェクトやマテリアルを1つにまとめられる」という事の真意
放送のコメントでは「管理が楽」と言いましたが、もう少し深く言及すると、下記の利点が有ります
- マテリアルが1つで済む
- GameObjectが1つで済む
- Z暴れ(Z-Fighting)を考慮しなくても良い
- 透過部分のOverDrawを考慮しなくて良い
1,2番目については言った通りです。マテリアルも、GameObjectも少ない方が管理が楽。
で、特に3番目ですが、UGUIのような描画順がHierarchyで制御できるならあまり重要ではないですが、
仮に3D空間で実装する場合にBase画像とOverlay画像をカメラから全く同じ距離に置いてしまうと、Z値(カメラからの奥行き情報)が干渉してしまいZ暴れ(Z-Fighting)が発生してしまいます。
「防ぐためには、Overlay画像をカメラに0.0001fだけ近づけて…」としても良いですが微妙に面倒臭いし、離し距離によってはカメラを傾けると隙間が微妙に見えてしまってダサいです。
そういったことを考えなくても良い = 楽 という事でもあります。
4番目については次項で説明します
重ね合わせによるフィルレートの倍化を考慮しなくても良い
重ね合わせの際に完全に透明な部分であったとしても描画負荷は発生します。
Base画像とOverlay画像が、仮に全画面に512x512pxで2枚重ね合わせで表示されたとして、ピクセルを打ち込む回数は512x512x2=524,288回発生する事になります。
これを1回の描画にまとめられるので512x512x1=262,144回のピクセル打ち込みになるため、GPUの負荷を抑えられます。
※厳密には、重ね合わせの時に使うシェーダと合成で表現するシェーダでは計算内容が違うので単純に50%の負荷軽減になるわけではないです;
#逆にシェーダで合成する欠点はある?
当然欠点もあるので何点か紹介します
- シェーダを用意しなければならない
- これはまぁその通りで、そもそもシェーダを準備する手間がかかります。さらに言及するとシェーダを扱える人でしか保守改良が難しくなってしまうので、見た目がおかしくなった時の責任がUnity側から自分に移ってくる点は覚悟しておいた方が良いです
- シェーダの内容次第では、重ね合わせよりも負荷が高くなってしまう
- 先ほど「重ね合わせよりもフィルレートを抑えられる」と書きましたが、Fragmentシェーダで難しい計算をしてしまうと、結局軽いシェーダで2枚描く以上の負荷になってしまう可能性は無きにしも非ずです。今回のシェーダはそれほど難しい計算はしませんが**「必ずしもまとめれば良いだけではない」**事は頭の片隅に置いておいた方が良いです。
- シェーダが増えるのでSRPBatcherでまとめづらくなってしまう
- UniversalRPで描画する際に重要なのは**「出来るだけ同じシェーダを使う事」**なので、スクラッチ用のシェーダを用意する時点でSRPBatcherでまとめる描画単位の対象外となってしまいます。今回の記事ではUniversalRPは扱わないので関係ないですが、UniversalRPで大量のオブジェクトをまとめて描きたい際に障害となり得るので注意しておいた方が良いでしょう
本題。0ベースからスクラッチ表現までを達成します。
先ずはプロジェクトを作る
という事で、0から作ってみましょう。
シェーダの合成処理のみにフォーカスするので、今回Patternテクスチャへのアルファ書き込み(スクラッチ削り取り処理)については言及しません。
そちらは先ほどのCAのリンク先をご覧ください。
環境は下記で進めます
- Unity-Hub 3.1.0 beta1
- Unity 2021.2.12f1
先ずは空のプロジェクトを作ります。
初学者向けなのでBuilt-In RenderPipelineの3Dプロジェクトにします。(今回UnversalRPについては触れません)
プロジェクト名はScratchTestで良いか。
シェーダを確認するための面の準備
シーンが出来たらHierarchyを右クリック > 3D Object > Plane で台紙となる面を作っておきます
出来ました。この面のマテリアルを見てみると
Unityの標準のマテリアルがセットされています。これを自前のスクラッチマテリアルに変えます。
マテリアルを追加
Projectツリービューで右クリック > Create > Material でマテリアルを追加
出来上がったマテリアルのInspectorからシェーダを見ると標準のStandardが設定されています。
Standerdは標準という名前ですが、その実結構な機能が実装されており、初学者が読もうとすると大変難しい内容となっております。
対して、昔からあるUnlit/Textureなどは非常にシンプルな内容なので、今回はそちらを元にスクラッチシェーダを作成していきます。
シェーダを追加
という事でここからはUnlit/Textureをベースに話していきます。
Projectツリービューで右クリック > Create > Shader > Unlit Shaderを選択すると、Unlit/Textureシェーダをベースとしたシェーダファイルが作成されます。
Scratchとでも名付けてやりましょう。
Unlit/Scratchシェーダとして、このシェーダをプロジェクトで扱えるようになります。
##面にマテリアルとシェーダを設定
早速シェーダファイルを開いて中身を編集したいところですが、その前にシェーダをマテリアルに設定し、更にマテリアルを面に適用しておきましょう。
Projectツリーから、Scratchマテリアルを選択し、InspectorからシェーダをUnlit/Scratchに変更します。
HierarchyからPlaneを選択して、Inspectorから、Materialsを選択し、0番にScratchマテリアルを設定します。
これにより**「Scrachシェーダを持ったScratchマテリアルでPlaneを描画」**することが出来るようになったわけです。
テクスチャを準備
せっかくなのでコーディングに入る前に必要なデータを揃えておきましょう
ここに3枚のテクスチャがあるじゃろ?という事で、それぞれBase.png、Pattern.png、Overlay.pngという名前で保存しておきます。
それぞれの意味は先ほどから書いてある通り
- Base.png … スクラッチの下の画像
- Pattern.png … スクラッチの削られ具合(黒(0)で削った部分、白(1)でまだ削られていない部分)
- Overlay.png … スクラッチの上の画像
です。重要なのはPattern.pngは黒=削り済み、という点でしょうか。
#シェーダの中身について
やっとコーディング。先ずはScratch.shaderファイルを開きましょう
Shader "Unlit/Scratch"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
}
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 100
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
// make fog work
#pragma multi_compile_fog
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
UNITY_FOG_COORDS(1)
float4 vertex : SV_POSITION;
};
sampler2D _MainTex;
float4 _MainTex_ST;
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
UNITY_TRANSFER_FOG(o,o.vertex);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
// sample the texture
fixed4 col = tex2D(_MainTex, i.uv);
// apply fog
UNITY_APPLY_FOG(i.fogCoord, col);
return col;
}
ENDCG
}
}
}
今更ここで各行を語るべくでもないですが、
今回は重要な点「下地の絵(Base)」「削り具合(Pattern)」「削る前の絵(Overlay)」の3枚のテクスチャを結合する処理に関わる部分だけ解説します。
プロパティの追加
一番上の部分。こちらはマテリアルからテクスチャや数値などを設定するための入り口です
Properties
{
_MainTex ("Texture", 2D) = "white" {}
}
察しの良い方ならすでに気付いているかもしれませんが、こちらにテクスチャを3枚設定できるように行を追加します
Properties
{
_MainTex ("Base", 2D) = "white" {}
_PatternTex ("Pattern", 2D) = "white" {}
_OverlayTex ("Overlay", 2D) = "white" {}
}
_MainTexはUnityで古くから慣例として付けられている名前なので、敢えてそのままにしておきます。
シェーダ内で利用できるようにする
プロパティを追加したことで、マテリアルからテクスチャを設定する入り口は準備できましたが、このままではまだシェーダ内部でテクスチャを使う事は出来ません。
シェーダで使いたいパラメータを明示するために、コード中央の定義部
sampler2D _MainTex;
float4 _MainTex_ST;
こちらも追加します。
sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _PatternTex;
float4 _PatternTex_ST;
sampler2D _OverlayTex;
float4 _OverlayTex_ST;
これで3つのテクスチャをシェーダ内で利用する事が出来るようになりました。
合成処理
これもまた察しの良い方ならすでに気付いているかもしれませんが、テクスチャの色を参照しているコードは
// sample the texture
fixed4 col = tex2D(_MainTex, i.uv);
こちらです。
合成するために3枚のテクスチャの色を取得しておきましょう。
// sample the texture
fixed4 baseCol = tex2D(_MainTex, i.uv);
fixed4 patternCol = tex2D(_PatternTex, i.uv);
fixed4 overlayCol = tex2D(_OverlayTex, i.uv);
色を合成するためには、自前で複雑な色計算をしても良いのですが、今回はlerp()関数を使います
lerp()関数は 最終的な色 = lerp( valueが0の時の色, valueが1の時の色, value ); という値を入れる事で、最終的な色としてvalueで指定した割合で二つの色を混ぜ合わせてくれる処理(線形補間と言います)を行う関数です。
つまり
最終的な色 = lerp( Base【削った後の色】, Overlay【削る前の色】, Pattern【削ったら0、削る前は1】 );
を入れる事で、スクラッチ表現が達成できるようになるわけです。
つまり
// sample the texture
fixed4 col = tex2D(_MainTex, i.uv);
を
// sample the texture
fixed4 baseCol = tex2D(_MainTex, i.uv);
fixed4 patternCol = tex2D(_PatternTex, i.uv);
fixed4 overlayCol = tex2D(_OverlayTex, i.uv);
fixed4 col = lerp(baseCol, overlayCol, patternCol.r);
とすることで、3つのテクスチャを合成した結果が得られます。
注意点としては、 valueには patternCol.r と、ベクトルではなく、1つの小数(赤成分のみ)を渡す ようにしている所です。
コードの完成
という事で出来上がったシェーダコードは以下になります
Shader "Unlit/Scratch"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_PatternTex ("Pattern", 2D) = "white" {}
_OverlayTex ("Overlay", 2D) = "white" {}
}
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 100
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
// make fog work
#pragma multi_compile_fog
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
UNITY_FOG_COORDS(1)
float4 vertex : SV_POSITION;
};
sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _PatternTex;
float4 _PatternTex_ST;
sampler2D _OverlayTex;
float4 _OverlayTex_ST;
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
UNITY_TRANSFER_FOG(o,o.vertex);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
// sample the texture
fixed4 baseCol = tex2D(_MainTex, i.uv);
fixed4 patternCol = tex2D(_PatternTex, i.uv);
fixed4 overlayCol = tex2D(_OverlayTex, i.uv);
fixed4 col = lerp(baseCol, overlayCol, patternCol.r);
// apply fog
UNITY_APPLY_FOG(i.fogCoord, col);
return col;
}
ENDCG
}
}
}
早速挙動を確認しましょう。
動作確認
Unityに戻って、Scratchマテリアルを選択。Inspectorから先ほど用意した3枚のテクスチャをそれぞれ当てはめます。
そして、SceneViewを確認すると…!
ちゃんとPatternの黒い部分(削った部分)だけ、Baseの色が表示されている事が確認できました!
という事でスクラッチの様に削った見た目になるシェーダの実装完了です。お疲れさまでした。
終わりに
出来るだけスクショも多めに、
初めて「自分でシェーダを書いてみよう!」と思った人にも取っつきやすい様に努めたつもりですがいかがでしょうか?
もしわかりづらい点などあればツッコミ頂けるとありがたいです。
境界線に色を塗る処理も紹介しようと考えていましたが、記事がかなり長くなってきたので続編で書こうと思います。
続き
早速続きを書きました。1日2回行動。