Edited at
VRChatDay 22

VRChatの皆に手軽に綺麗になってほしくて作ったシェーダーの話


はじめに

この記事はVRChat Advent Calendar 2018の22日目の記事です

昨日の記事は、@sunasaji さんの「VRCで遊んだワールドとフレンドの履歴を見る」でした。


概要

要するに、Arktoon-Shaders(GitHub / BOOTH)の話です。

大きく3つに分けて書いていきます。


  • VRChatのライティング事情

  • Arktoon実装の話

  • 雑にまとめる


VRChatのライティング事情

シェーダーを作るきっかけにもなった、VRChatのライティングの話です。

※経験則が大部分を占めるため、話半分くらいの認識で。


ワールドライティング事情

ライティングの概要(Unity Documentation) - つまり光源と陰影です。

Unity上で使用されるライトコンポーネントをいくつか抜粋しました。

- DirectionalLight

- Point/Spot Light

- Area Light

- Environment Light

- (Global Illumination)

- (Reflection)

- その他Emissive設定されたオブジェクトとか

VRChatでワールドを作る際は、上記の機能全てが使用できます。(Realtime GIだけ少し準備があります。)

ワールド製作者は各々の思想と直感により、上記コンポーネントから一つまたは複数の組み合わせを使い、絵作りを進めていくことができます。

VRChat_1920x1080_2018-12-20_21-50-34.163.png

Japan Shrine by RootGentle

個人的には、雰囲気が重視されていくほど、DirectionalLightの優先度は下がり、ベイクされた大量のPointLightとProbe、EnvironmentLightに凝った造りになる印象がります。

VRChat_1920x1080_2018-12-20_21-52-55.523.png

SilentBeach by tachikoma390


アバターライティング事情

さてここで、

「じゃあキャラクターも綺麗に表現したいし、PBS(Standardシェーダー)でいいじゃん。」

となれば一件落着です。ですが私はそうならなかったので、一気に話は面倒になります。

可愛くできたアバターに、物理ベースの陰影処理が入ったStandardシェーダーを適用すると、大体が図のようになってしまいます。

image.png

(極端な例になってしまっているのはご容赦下さい)

そうなると、先に述べたワールドのためのライティングとは関係なく「可愛い3Dモデルに相応しい絵作りができるカスタムシェーダー」が欲しくなるのも無理はないでしょう(と思ってます。たぶん。そうですよね?)


トゥーンシェーダーについて

少しググっただけでも、要求を満たしてくれそうなカスタムシェーダーは大量にでてきます。

一部を抜粋しました。

ユニティちゃんトゥーンシェーダー(1 / 2)

カマクラシェーダーズ

Toony Colors Pro

(VRChat想定のシェーダーについては後述します)

他にも個人製作・配布しているシェーダーが沢山あり、配布形態も、アセットストアだったり、VRCat(海外の半公式フォーラム)、BOOTHだったり、いろいろありました。

自分も、これらからいくつか気になったシェーダーを確認し、自分好みの表現を追求していく予定でしたが。これらには少し問題がありました。

冒頭で取り扱った、ワールドライティングの機能に完全に対応できたシェーダーは、実はほとんど存在していません。

また、対応していたとしても、カスタムシェーダー制作者が意図していない要素の扱いが極端に雑だったり、絵作りの統一感を維持できる物ではありませんでした。

現象は様々で、


  • 頂点ライティング(後述)に対応していない

  • グローバルイルミネーションを雑に扱っててワールドごとに印象が大きく崩れる

  • そもそもポイントライトの減衰を取得していない

などありました。

トゥーンシェーダーに関して言えばそうなるのも当然で、理由は「対応させるメリットがない」の一言に尽きます。

せっかく、DirectionalLightを1つ使って綺麗なセルシェーディングを実現したのに、頂点ライトやグローバルイルミネーションを適用して台無しにしたくなんてありません。

また、シーンを現実感のあるライティングをデザインしたとしても、後からレイヤーを分けて、

カスタムシェーダーの製作者が指定した通りのキャラクター用のライトを別で用意することで、ワールド・キャラクター共に質の高い表現が簡単に実現できました。

で、VRChatは、それができる状況でないため、問題になりました。

ワールド制作者の大体は、訪問者がどのようなカスタムシェーダーをつけてくるかなんて知ったことではなく、優秀なビルトインシェーダーであるStandardを基準にワールドの絵作りを進めていくものかと思います。筆者はそうでした。

そのため、各カスタムシェーダーが対応していないライティング要素が使われたワールドに入ると、真っ暗になってしまったり、何故か、鏡に映る自分だけが変色してしまう。というケースが昔はよく起きていました。


VRChat向けトゥーンシェーダー

VRChatに適したシェーダーはないか、もう一度キーワードを変えてググってみたりすると、

おそらく下記のようなものがヒットしてくるはずです。

これらは所謂「VRChatでの使用を想定」されたカスタムシェーダーになっており、

上記で述べたVRChatにおけるライティング事情をある程度汲んだ実装になっております。

少なくとも、どんなワールドに入ったとしてもアバターが真っ暗になることは無いと思います(もちろん、設定次第ですが。)

ただ、こちらは国産のものが少なく、機能が日本人向けではなかったり、そもそもマニュアルが英語など、デメリットもいくつかありました。また、殆どのワールドで一定の見栄えは保証されるものの、先述のカスタムトゥーンシェーダーよりは機能は限定的になりがちな印象でした。

(個人的にはTypeA Anime Shaderはとっても使いやすく、Arktoonより先に出てたらこれを使っていたかもしれない)


さて

そんなこんなで数日燻っていたところ、

image.png

というコメントを友人より頂きました。

たしかに。やるか。


Arktoon実装の話

ここから先は、Arktoon-Shadersの実装に関して話していきます。

一気にシェーダー内部の話になるので、難しいことは要らない!という方はここはスキップしましょう


VRChat向けとは?

「VRChat向け」とは何か。そのために何をしたか。

個人差があると思いますが、自分は以下のように列挙しました。

括弧がついているものは後付けだったりしたものです。


  • VRChat で使用できること


    • そこそこな表現ができること

    • 使いやすいこと

    • Forward Renderingで適切に描画できること



      • 起こりうる全てライティングに対応できていること


        • ピクセル単位ライティング(Direcctional Light/PointLight/SpotLight)

        • 頂点単位ライティング(PointLight/SpotLight)

        • 球面調和(PointLight/SpotLight/Environment Lighting などがベイクされたもの)

        • (Reflection)





    • Realtime GIを使用しないこと

    • VR対応であること

    • Unity 5.6.3p1で正常に利用できること

    • (Unity2017でも利用できること)



1つずつ実装を交えて掻い摘みます。


そこそこな表現ができること

何はともあれ、要件が必要です。

とりあえず下記の表現の実現を目的して開発を開始しました。

image.png

(あれ?最初MatCap無かったのか・・・)

陰に関する処理以外は基本的に、

Shader Forgeでやりたい表現を作り、ソースコードを生成する。


Arktoon-Shadersに移植し、クリーンアップする。ついでに光源の強さを乗算する

上記の繰り返しで実装していきました。


使いやすいこと

Arktoon-Shadersを作る上でけっこう重要視していた項目の一つです

Unityには カスタムマテリアルエディタ があり、シェーダーのプロパティに対してリッチなUIを提供するための仕組みがあります。

Arktoonでは最終的には「表現したい内容」でカテゴライズし、それぞれの作業単位でマニュアル(Googleドキュメント)を構成することでなんとかしました。また、必要のない情報を見せて視覚への負荷を与えてしまわないよう、不必要な機能は畳まれるようにしました。

image.png

これは「VRChatのアバターをアップロードするユーザーが、Unity開発を経験したことがある確率」を考えた結果、

おそらく無視できないほど低く、「非開発者でも分かるUI」「迷ったらマニュアル」の導線実装は必須と判断しての条件設定でした。


Forward Rendering で適切に描画できること

さて、


  • ShaderLabに関してある程度の知識がある(頂点シェーダー、フラグメントシェーダーの動き方)

  • Unityのビルトインの関数と変数はカオスであることを知っている

ここから上記を前提にがっつり開発の話になります。

VRChatはUnityのForward Renderingを採用しています。

Forward Renderingの概要は、Unity公式ドキュメントページ で解説されています。

作る上では実装の詳細の段落をちゃんと理解する必要がありますが、雑に掻い摘むと


  • ライトの重要度によって、ピクセル単位・頂点単位・球面調和の3つの情報に分類される

  • 以下の3つのライトは、「ベースパス(ForwardBase)」として処理されます。


    • ピクセル単位1つ(=最も明るいDirectional Light)

    • 頂点単位(最大4つ)、

    • 球面調和



  • 追加のピクセル単位のライト(PointLightなど、プロジェクト設定により数は増減)は、「加算パス(ForwardAdd)」として処理されます。


ベースパス(ForwardBase)

ForwardRenderingを使っている以上(たとえ光源が無かったとしても)

必ず1つのマテリアルに対して必ず1回は実行される「描画単位」です。

ベースパスは、これから説明する3つの光源を一度に計算して描画しています。


ピクセル単位(ForwardBase)

fragment関数で_WorldSpaceLightPos0_LightColor0を使って表現します。

無限遠光源なため、距離減衰はありません。

_WorldSpaceLightPos0に光の向きが入り、_LightColor0に光源の色が強度を乗算して入っています。

ここは特に難しくもなく、ノードベース系も対応しているので引用するだけで実現できました。


frag関数

float4 frag(VertexOutput i) : COLOR {

...
float3 lightDirection = normalize(_WorldSpaceLightPos0.xyz + float3(0, +0.0000000001, 0));
float3 lightColor = _LightColor0.rgb;
...
}


頂点単位(ForwardBase)

なかなかやっかいな奴で、

float4 unity_4LightPosX0, float4 unity_4LightPosY0, float4 unity_4LightPosZ0

half4 unity_LightColor[]

float4 unity_4LightAtten0

の5つの情報から受光状態を組み立てないといけません。

通常であればUnityに備わっているヘルパー関数である Shade4PointLightsを以下のように渡すことで、計算済みの間接光として受け取ることができます。

ambient = Shade4PointLights(

unity_4LightPosX0,
unity_4LightPosY0,
unity_4LightPosZ0,
unity_LightColor[0].rgb,
unity_LightColor[1].rgb,
unity_LightColor[2].rgb,
unity_LightColor[3].rgb,
unity_4LightAtten0,
worldPos,
worldNormal);

...が、arktoonは受光の方法をプロパティで設定する必要があるため、計算済みの値ではNGでした。

そのため、Shade4PointLight砕いて(且つ、ちょっと計算に変更を加えて)実装しました。


arkrudeVertGeom.cginc抜粋(改変あり)

struct VertexOutput {

...
float3 lightColor0 : LIGHT_COLOR0;
float3 lightColor1 : LIGHT_COLOR1;
float3 lightColor2 : LIGHT_COLOR2;
float3 lightColor3 : LIGHT_COLOR3;
float4 ambientAttenuation : AMBIENT_ATTEN;
float4 ambientIndirect : AMBIENT_INDIRECT;
...
};

void geom(triangle v2g IN[3], inout TriangleStream<VertexOutput> tristream)
{
...
o.lightColor0 = unity_LightColor[0].rgb;
o.lightColor1 = unity_LightColor[1].rgb;
o.lightColor2 = unity_LightColor[2].rgb;
o.lightColor3 = unity_LightColor[3].rgb;
#if UNITY_SHOULD_SAMPLE_SH && defined(USE_VERTEX_LIGHT)
// Shade4PointLightsを展開して改変
// {
// to light vectors
float4 toLightX = unity_4LightPosX0 - o.posWorld.x;
float4 toLightY = unity_4LightPosY0 - o.posWorld.y;
float4 toLightZ = unity_4LightPosZ0 - o.posWorld.z;
// squared lengths
float4 lengthSq = 0;
lengthSq += toLightX * toLightX;
lengthSq += toLightY * toLightY;
lengthSq += toLightZ * toLightZ;
// don't produce NaNs if some vertex position overlaps with the light
lengthSq = max(lengthSq, 0.000001);

// NdotL
float4 ndotl = 0;
ndotl += toLightX * (o.normalDir.x * lerp(1, -1, _DoubleSidedFlipBackfaceNormal));
ndotl += toLightY * (o.normalDir.y * lerp(1, -1, _DoubleSidedFlipBackfaceNormal));
ndotl += toLightZ * (o.normalDir.z * lerp(1, -1, _DoubleSidedFlipBackfaceNormal));

// correct NdotL
float4 corr = rsqrt(lengthSq);
ndotl = max (float4(0,0,0,0), ndotl * corr);
// attenuation
float4 atten = 1.0 / (1.0 + lengthSq * unity_4LightAtten0);
float4 diff = ndotl * atten;
// }
o.ambientAttenuation = diff;
o.ambientIndirect = sqrt(min(1,corr* atten));
#else
o.ambientAttenuation = o.ambientIndirect = 0;
#endif
...
}



arkrudeFrag抜粋

float4 frag(VertexOutput i) : COLOR {

...
// 頂点ライティング:PixelLightから溢れた4光源をそれぞれ計算
#ifdef USE_VERTEX_LIGHT
float VertexShadowborderMin = max(0, _PointShadowborder - _PointShadowborderBlur/2.0);
float VertexShadowborderMax = min(1, _PointShadowborder + _PointShadowborderBlur/2.0);
float4 directContributionVertex = 1.0 - ((1.0 - saturate(( (saturate(i.ambientAttenuation) - VertexShadowborderMin)) / (VertexShadowborderMax - VertexShadowborderMin))));
// #ifdef USE_POINT_SHADOW_STEPS
directContributionVertex = lerp(directContributionVertex, min(1,floor(directContributionVertex * _PointShadowSteps) / (_PointShadowSteps - 1)), _PointShadowUseStep);
// #endif
directContributionVertex *= additionalContributionMultiplier;
float3 coloredLight_0 = max(directContributionVertex.r * i.lightColor0 * i.ambientAttenuation.r, i.lightColor0 * i.ambientIndirect.r * (1-_PointShadowStrength));
float3 coloredLight_1 = max(directContributionVertex.g * i.lightColor1 * i.ambientAttenuation.g, i.lightColor1 * i.ambientIndirect.g * (1-_PointShadowStrength));
float3 coloredLight_2 = max(directContributionVertex.b * i.lightColor2 * i.ambientAttenuation.b, i.lightColor2 * i.ambientIndirect.b * (1-_PointShadowStrength));
float3 coloredLight_3 = max(directContributionVertex.a * i.lightColor3 * i.ambientAttenuation.a, i.lightColor3 * i.ambientIndirect.a * (1-_PointShadowStrength));
float3 coloredLight_sum = (coloredLight_0 + coloredLight_1 + coloredLight_2 + coloredLight_3) * _PointAddIntensity;
#else
float3 coloredLight_sum = float3(0,0,0);
#endif
...
}

これでcoloredLight_sumにプロパティの値を反映した受光状態を表現できました。


球面調和(ForwardBase)

球面調和として入ってくる情報には、


  • 頂点単位の4枠から漏れた優先度の低いPoint/SpotLight

  • ベイクによって焼き込まれたLightProbeの情報

  • シーンのライティング設定にあるEnvironment Lighting

が一括で入ってくるため、Bakedなライトと一緒に計算ができます。逆に言うと、まとめて計算することしかできません。

これも、fragment関数内でビルトイン関数であるShadeSH9を使うことで、特定の向きで受けるべき光の情報を取得できます。

...が、例によってarktoonは本来の使い方では実現できなかったため、手を加えています。


arkludeFrag.cginc抜粋

    // 光源サンプリング方法

#ifdef _LIGHTSAMPLING_ARKTOON
// 明るい部分と暗い部分をサンプリング・グレースケールでリマッピングして全面の光量を再計算
float3 ShadeSH9Plus = GetSHLength();
float3 ShadeSH9Minus = ShadeSH9(float4(0,0,0,1));
#elif _LIGHTSAMPLING_CUBED
// 空間上、真上を向いたときの光と真下を向いたときの光でサンプリング
float3 ShadeSH9Plus = ShadeSH9Direct();
float3 ShadeSH9Minus = ShadeSH9Indirect();
#endif


arklludeOther.cginc抜粋


// Arktoonサンプリング方式
// 最も明るい部分を取得(ちょっと語弊があるけど大体あってる)
half3 GetSHLength ()
{
half3 x, x1;
x.r = length(unity_SHAr);
x.g = length(unity_SHAg);
x.b = length(unity_SHAb);
x1.r = length(unity_SHBr);
x1.g = length(unity_SHBg);
x1.b = length(unity_SHBb);
return x + x1;
}

// ShadeSH9(float4(0,0,0,1)) は全ての向きから受ける光の平均量を示している
// (これも大体あってるだけ)

// Cubedサンプリング方式
float3 ShadeSH9Direct(){
// ワールド座標で真上(固定)を向いたときの受光設定
return ShadeSH9(half4(0.0, 1.0, 0.0, 1.0));
}
float3 ShadeSH9Indirect(){
// ワールド座標で真下(固定)を向いたときの受光設定
return ShadeSH9(half4(0.0, -1.0, 0.0, 1.0));
}



Reflection Probe

Reflection Probeに関しては、殆どビルトイン関数を使用しているため、説明は省略します。

挙動に興味がある方は、公式ドキュメント や 凹みTipsさんのStandard Shader解体新書 のページを参照してみてください。


加算パス(ForwardAdd)

ForwardBase とは異なり、描画する単位が分かれています。

2つ目以降のDirectionalLightと、優先度の高いPoint/Spot Lightに対して実施されます。

ただし、ライト1つにつき描画単位が1回増える ため、処理コストが高いです。

何回実施されるかは、Unityのプロジェクト設定の PixelLightCountで設定されます。


追加のライト

ForwardBaseで、DirectionalLightでやったときと同じ感じで良いので楽です。

気にすべきは、加算パスがポイントライトによるものだった場合に、UNITY_LIGHT_ATTENUATIONマクロに減衰情報が入っているため、

これを利用して乗算する必要があります。


arkludeAdd抜粋

float4 frag(VertexOutput i) : COLOR {

...
// DirectionalLight(w=0)だったら、_WorldSpaceLightPos0をそのまま使い、
// その他(w=1)だったら、現在の地点との差で光の向きを設定
float3 lightDirection = normalize(lerp(_WorldSpaceLightPos0.xyz, _WorldSpaceLightPos0.xyz - i.posWorld.xyz,_WorldSpaceLightPos0.w));
float3 lightColor = _LightColor0.rgb;
...
// 光の強度の設定。
float lightContribution = dot(lightDirection, normalDirection)*attenuation;
...


Realtime GIを前提にしないこと

VRChatでは、Realtime GIの更新を停止しています。(Custom Render Behaviorを含んだワールドオブジェクトのみ更新されます)

そのため、アバターに対して、それを前提としたシェーダーは書けません。

・・・といっても、LightProbeが動的に更新されるだけなので、特にシェーダーで考慮する必要はありませんでした。


VR対応であること

_WorldSpaceCameraPosをはじめとしたあらゆるビルトイン変数が、Unity側で対応してくれているため、

特にシェーダーで考慮する必要はありません

・・・と言いたいところでしたが「視差」に関して気を付けるべき箇所がいくつかありました。


Parallax Mapping

もともと「視差」を目的とした表現なので、問題にはならなかった(むしろ立体感が増えたため、かなり画になりました)


MatCap(viewDirection(視点からの向き)を使った最近の形式)

「視差」が逆に問題になったのはこっちです。



このため、MatCapはかねてから実装してた形式(視点のベクトルのみを使って貼り付ける)をデフォルトとしています。


arkludeFrag抜粋

float4 frag(VertexOutput i) : COLOR {

// ...
#ifdef USE_POSITION_RELATED_CALC
// viewDirectionを使ってMatCapテクスチャを貼り付ける
// ...視差によって違和感が凄まじくなるので、旧型式[_old]をブレンドしている
float3 transformMatcapViewDir = mul( UNITY_MATRIX_V, float4(viewDirection,0) ).xyz * float3(-1,-1,1) + float3(0,0,1);
float3 transformMatcapNormal = mul( UNITY_MATRIX_V, float4(normalDirectionMatcap,0) ).xyz;
float2 transformMatcap_old = transformMatcapNormal.rg*0.5+0.5;
transformMatcapNormal *= float3(-1,-1,1);
float3 transformMatcapCombined = transformMatcapViewDir * dot(transformMatcapViewDir, transformMatcapNormal) / transformMatcapViewDir.z - transformMatcapNormal;
float2 transformMatcap = lerp(((transformMatcapCombined.rg*0.5)+0.5), transformMatcap_old, max(0,transformMatcapNormal.z));
#else
// 視線方向のみを考慮してMatCapテクスチャを貼り付ける
// 視差の違和感はないが、視界の端から見た時にMatCapテクスチャが裏返る。
float2 transformMatcap = (mul( UNITY_MATRIX_V, float4(normalDirectionMatcap,0) ).xyz.rg*0.5+0.5);
#endif
// ...
}


Unity 5.6.3p1で正常に利用できること

もともとShader ForgeがUnity5.6.3p1で大体動いたため、特に気にしてはいませんでした。

強いて言えば、LIGHT_ATTENUATIONUNITY_LIGHT_ATTENUATIONに更新する必要がありました。


(Unity2017でも利用できること)

エディタ拡張を作った時にちょっと問題がありました。

マジでこんな書き方するの?って感じの。あまり考慮しすぎると可読性が著しく低下しそう。


ArktoonManager.cs抜粋

            // Unity2017であればSendWebRequest()を使う

#if UNITY_2017_OR_NEWER
www.SendWebRequest();
#else
// Unity2017より古い場合は、**警告表示を無効にして** 従来の形式であるSend()を使う
#pragma warning disable 0618
www.Send();
#pragma warning restore 0618
#endif

VRChatがUnity2017になって暫く経ったら取り除こうと思います。


雑にまとめる

というわけで、ここまでだらだら書いてきた思想と仕組みで、Arktoon-Shadersは作成・維持をしていっております。

おかげ様で、BOOTHで検索しても結構な数のモデルで採用させていただいております。

また、テストに付き合って頂いたり、先人様方のアドバイスも色々頂いたうえで、

0.5.0をリリースした2018/08/05から

0.9.6.0をリリースした2018/12/06までこつこつと改善を続けることができました。

一通り入れたい機能は入り、VRChatでもそこそこ問題なく表示できているため、しばらくは更新が鈍化すると思います。

まだまだじっくりメンテナンスしていきたいと思いますので、今後ともよろしくお願いいたします。

もしArktoon-Shadersに関して気になることがあれば、

Twitterで@synqarkまで連絡するか、

DiscordのArktoon/VRWaterShaderに関する専用サーバーがありますので、お気軽にお声がけ下さいませ。


おわり

明日は、@kajitaj63b3 さんの記事になります。