Help us understand the problem. What is going on with this article?

if文を使わずに条件分岐してVRChat用の時計を作りたい!

More than 1 year has passed since last update.

はじめに

はじめまして!!ギルティです!!
普段はVRChatでピアノを演奏したりしています。
ほかにも、ワールドを作ったり、アバターをいじったり、Shaderを書いたり、浅く広くいろいろしていますw

今回は、お値段以上◯トリでめっちゃかわいい猫の時計が売ってたのでこれをVRChatでも使えたらいいなぁとか思って作ったので、その解説をしようと思います。
はっきりいって、ほとんど人の技術を パクった 参考にしただけなので、詳しいことについてはページ最下部の参考文献を見ていただけたらと思います。
質問されても答えられないので絶対に質問しないでください。

この記事を読んでる方々はご存知かもしれませんが、VRChatではスクリプト、つまりC#が使えません。
なので、いわゆるShader芸というShaderLab言語を用いた実装をしなければ複雑なものは作れません(絶対そうとはいえない)。
しかし、ShaderLab言語には気をつけなければならない点があります。
それは、なるべくif文を使わないほうがいい、です。
とはいっても、「ifを使わずには条件分岐できない....どうしたものか....。」となると思います。
それを解決するべく、今回はif文を使わずに条件分岐しようと思います。

環境

Unity : 2017.4.15f1
Blender : 2.79b
OS : Windows10 Pro 1903
CPU : Intel(R) Core(TM) i5-4690 CPU @ 3.50GHz
メモリ : 16GB
グラフィックボード : GeForce GTX 1060 3GB

なぜShaderでif文を使ってはいけないの?

Shaderでif文を使うと、パフォーマンスが低下します。
その要因として、Shaderが2x2の4ピクセルで同時実行されていることが関係します。
4ピクセルで同時実行されているため、ピクセルAではif文内の処理が実行し終わってもピクセルB,C,Dではまだまだelseの処理に時間がかかる、ということが起きる可能性があります。
AさんがBさん、Cさん、Dさんを待つような状態になるわけですね。
つまり、ifがTrueかFalseか判断して中の処理を実行しているように見えて、実際はifとelse両方の処理をしているためパフォーマンスが低下するのです。
以上から、Shaderというのはパフォーマンスにかなり密接に関わることがわかったと思います。
そのため、なるべくif文を書かずにShaderを書くというのはとても重要になります。

ifとifdefは違うの?

Unityを使ってると、勝手にShaderをコンパイルしてくれるのでShaderLabがインタプリタ言語だと無意識に錯覚しそうですが、実際は違います。
ざっくりとifとifdefについて解説すると、ifはゲーム実行中にどっちにするかその都度考えているのに対し、ifdefはゲーム実行中にはコンパイル済みなのでもうifとelseどっちにするか決まっているといった感じです。
つまり、ifdefはif文とは違うということですね。

※あくまでざっくりとした解説なので、厳密性を求める方は参考文献に目を通すことをおすすめします。

どんなものを作ったか

ソースコードは以下の通りです。

UnlitPendulumClock.shader
Shader "Guilty/UnlitPendulumClock" {
    Properties {
        _Texture ("Texture", 2D) = "black" {}
        _MaxDegree ("Max Degree", Range(0, 360)) = 30.0
        _TailSpeed ("Tail Speed", Range(-20, 20)) = 1.0
        _MainTex ("Sync Texture", 2D) = "white" {}
    }
    SubShader {
        Tags {
            "IgnoreProjector" = "True"
            "Queue" = "Transparent"
            "RenderType" = "Transparent"
        }
        LOD 200

        Blend SrcAlpha OneMinusSrcAlpha
        Cull Back
        ZWrite On

        Pass {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma multi_compile_fog
            #pragma multi_compile APPLY_GAMMA_OFF GAMMA
            #pragma multi_compile TICKTACK_OFF TICKTACK

            #include "UnityCG.cginc"

            sampler2D _Texture;
            float4 _Texture_ST;
            float _MaxDegree;
            float _TailSpeed;
            sampler2D _MainTex;
            float4 _MainTex_ST;

            struct appdata {
                float4 vertex: POSITION;
                float2 uv: TEXCOORD0;
                float4 color: COLOR;
                UNITY_VERTEX_INPUT_INSTANCE_ID
            };

            struct v2f {
                float4 vertex: SV_POSITION;
                float2 uv: TEXCOORD0;
                float4 color: COLOR;
                UNITY_FOG_COORDS(1)
                UNITY_VERTEX_OUTPUT_STEREO
            };

            int getHour(float3 textureFloats) {
                return round(((textureFloats.x + textureFloats.y + textureFloats.z) / 3) * 24); // 0.xxxxxx * 24
            }

            int getMinSec(float3 textureFloats) {
                return round(((textureFloats.x + textureFloats.y + textureFloats.z) / 3) * 60); // 0.xxxxxx * 60
            }

            v2f vert(appdata v) {
                float3 first = pow(tex2Dlod(_MainTex, float4(0.25, 0.75, 0, 0)).rgb, 1/2.2);
                float3 second = pow(tex2Dlod(_MainTex, float4(0.75, 0.75, 0, 0)).rgb, 1/2.2);
                float3 third = pow(tex2Dlod(_MainTex, float4(0.25, 0.25, 0, 0)).rgb, 1/2.2);

                float3 offsetTime = float3(getHour(float3(first.r, second.g, third.b)), getMinSec(float3(first.g, second.b, third.r)), getMinSec(float3(first.b, second.r, third.g)));

                float time = (tex2Dlod(_MainTex, float4(0.75,0.25,0,0)).r < 0.5) ? (((offsetTime.r * 60 * 60) + (offsetTime.g * 60) + (offsetTime.b)) + _Time.y) : 0;

                float m = time / 60 / 60 * 2 * UNITY_PI * step(0.02575, v.vertex.z) * step(v.vertex.z, 0.02625);
                float h = time / 60 / 60 / 12 * 2 * UNITY_PI * step(0.02625, v.vertex.z);
                float tail = _MaxDegree * sin(_TailSpeed * _Time.y) * step(v.vertex.z, 0.01025) * UNITY_PI / 180; 

                v.vertex.xy = float2(v.vertex.xy.x * cos(m) - v.vertex.xy.y * sin(m), v.vertex.xy.y * cos(m) + v.vertex.xy.x * sin(m));
                v.vertex.xy = float2(v.vertex.xy.x * cos(h) - v.vertex.xy.y * sin(h), v.vertex.xy.y * cos(h) + v.vertex.xy.x * sin(h));
                v.vertex.xy = float2(v.vertex.xy.x * cos(tail) - v.vertex.xy.y * sin(tail), v.vertex.xy.y * cos(tail) + v.vertex.xy.x * sin(tail));

                v2f o;
                UNITY_SETUP_INSTANCE_ID(v);
                UNITY_INITIALIZE_OUTPUT(v2f, o)
                UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = v.uv;
                o.color = v.color;
                UNITY_TRANSFER_FOG(o,o.vertex);
                return o;
            }

            float4 frag(v2f i) : COLOR {
                fixed4 col = tex2D(_Texture, i.uv);
                UNITY_APPLY_FOG(i.fogCoord, col);
                return col;
            }
            ENDCG
        }
    }
}

動作は以下の通りです。
qiita.gif

これは地味に驚いたのですが、VRC_PanoramaはUnityのプレイボタンを押すだけで実行できるんですね。
てっきり、VRChat以外では使えないように実装されているとばかり思っていました。
(「自作アプリにVRC_Panoramaを組み込んじゃえ!」なんてことするつもりは僕はありませんが、果たしてこれができてしまう状況はいいのだろうか、VRChat運営さん....。)

解説

VRChatではスクリプトが使えないと説明したばかりですが、実は全く使えないわけではないです。
というのは、VRCSDKで用意されているスクリプトやダイナミックボーンや一部のスタンダードアセットなどは使えるからです。
そのVRCSDKの中にVRC_Panoramaというものがあります。
これを使うと、指定したURLから画像を引っ張ってきてShaderのPropertiesの_MainTexにセットしてくれます。
つまり、サーバーで時間情報を画像に落とし込み、そのURLをVRC_Panoramaで設定し、Shader内で画像を情報としてデコードして短針と長針が動くように設計することで、VRChat内で現実世界と同じ時刻で時計が動作するというわけです。
しかし、今回は主題とは話がそれるためVRC
Panoramaやサーバーの話はここで終わりです。

それでは本題に入っていきます。

設計の思考過程

まず、この時計を設計する"思考"過程を示そうと思います。
1. 猫の時計を作りたい
2. 長針と短針とあと尻尾も動かしたい
3. 回転行列で回転させれば実現できそう
4. それぞれ別々の回転行列を使わないといけない
5. if文は使いたくない
6. 頂点座標を入力値として、回転させたくない頂点は0度回転させるような計算式を書こう
7. step関数使えばできそう
8. 頂点座標を知ってる上でShaderを書かないといけない
9. Blenderで頂点をきれいに調整したモデルを作ろう
と、こんな感じでだいたいの作り方を決めました。

設計過程

次に、実際の設計過程を示そうと思います。

1.Blenderで頂点座標をメモしつつモデリングする

次の2つの画像は時計の全体像です。
qiita1.PNG
これは、斜めから見た時計の全体像です。

qiita2.PNG
これは、横から見た時計の全体像です。
分かりにくいと思いますが、左から短針、長針、時計(猫)本体、尻尾となっています。

以降、画像左側を表(おもて)と称します。
また、画像左下の3軸はあてにしないでください。
qiita3.PNG
これは、尻尾の表面の頂点座標を選択した状態です。
Z座標は1.00000に統一しました。

qiita4.PNG
これは、時計(猫)の裏面の頂点座標を選択した状態です。
Z座標は1.05000に統一しました。

qiita5.PNG
これは、時計(猫)の表面の頂点座標を選択した状態です。
Z座標は2.55000に統一しました。

qiita6.PNG
これは、長針の頂点座標を選択した状態です(長針は1枚の面のため表裏一体)。
Z座標は2.60000に統一しました。

qiita7.PNG
これは、短針の頂点座標を選択した状態です(短針は1枚の面のため表裏一体)。
Z座標は2.65000に統一しました。

2.頂点座標を考慮してShaderを書く

それでは、頂点座標がどうなっているかはわかっているので、それを考慮してif文に相当する0度回転の計算式を実装していきます。
0度回転といっても、特定の頂点座標ではちゃんと回転するようにします。
回転部分の実装は以下のようになります。

UnlitPendulumClock.shader(一部)
// 長針の回転角度 = 長針の回転回数 x 2π x 長針であるか否か(0 or 1)
float m = (time / 60 / 60) * (2 * UNITY_PI) * (step(0.02575, v.vertex.z) * step(v.vertex.z, 0.02625));
// 短針の回転角度 = 短針の回転回数 x 2π x 短針であるか否か(0 or 1)
float h = (time / 60 / 60 / 12) * (2 * UNITY_PI) * (step(0.02625, v.vertex.z));
// 尻尾の回転角度 = 最大振れ角 x sin(尻尾の速さ x 経過時間[s]) x 尻尾であるか否か(0 or 1) x π ÷ 180
float tail = _MaxDegree * sin(_TailSpeed * _Time.y) * step(v.vertex.z, 0.01025) * UNITY_PI / 180; 

// xy頂点座標行列 = (元のxy頂点座標行列) x (回転行列)
v.vertex.xy = float2(v.vertex.xy.x * cos(m) - v.vertex.xy.y * sin(m), v.vertex.xy.y * cos(m) + v.vertex.xy.x * sin(m));
v.vertex.xy = float2(v.vertex.xy.x * cos(h) - v.vertex.xy.y * sin(h), v.vertex.xy.y * cos(h) + v.vertex.xy.x * sin(h));
v.vertex.xy = float2(v.vertex.xy.x * cos(tail) - v.vertex.xy.y * sin(tail), v.vertex.xy.y * cos(tail) + v.vertex.xy.x * sin(tail));

初見ではおそらくstep(xxxx, yyyy)の箇所が理解に苦しむと思います。
解説すると、step関数はyyyyがxxxx未満(xxxx > yyyy)のときは0を、xxxx以上(xxxx <= yyyy)のときは1を返します。
これを利用し、頂点座標をstep関数の入力値とします。
時計の頂点座標について振り返ると、
・短針の頂点座標は2.65000
・長針の頂点座標は2.60000
・時計(猫)の表面の頂点座標は2.55000
・時計(猫)の裏面の頂点座標は1.05000
・尻尾の表面の頂点座標は1.00000
でした。
つまり、
・短針と長針の間は2.62500
・長針と時計(猫)の間は2.57500
・時計(猫)と尻尾の間は1.02500
となります。
この値をstep(xxxx, yyyy)のxxxxに入力します。
yyyyにはv.vertex.zを入力します。
現在計算しようとしている頂点の座標ですね。
以上が、コメントを除いた最初の3行です。
次の3行ではもとの頂点座標行列に回転行列を乗算しています。
しかし、最初の回転行列では長針の頂点座標のみが回転し、他の頂点座標は回転しません(0度回転する)。
2行目の回転行列では短針の頂点座標のみが回転し、他の頂点座標は回転しません(0度回転する)。
3行目の回転行列では尻尾の頂点座標のみが回転し、他の頂点座標は回転しません(0度回転する)。

補足

Q. メッシュを別々にして回転行列を乗算するとか、Animationを作ったりとか、シェイプキーを設定するとかでもできるんじゃないの?
A. それでもできるかもしれませんが、マテリアルが増えたりすると思います。増えないにしても、一つのオブジェクトとしてコントロールできるのは管理の面からするととても優れているといえると思います。また、Shaderの勉強にもなりますし、Shaderに加筆をすることでさまざまな機能が追加でき、マテリアルのPropertiesをいじるだけでいいユーザーライクな状態にするという点でもメリットはあると思います。

Q. if文を使ってないので猫の時計はパフォーマンスがいいんですよね?
A. たしかにif文を使わなければ軽くなることはありますが、逆に重くなる場合もあります。無理やり計算式で条件分岐を実装してパフォーマンスが重くなる場合もあるので、その点には注意するべきだと思います。今回作った猫の時計は、あくまでShaderの勉強がメインの目的です。なので、パフォーマンスのチェックは行ってませんし、if文を使った方が軽いという可能性も十分あります。

おわりに

なるべくわかりやすく解説したつもりでしたが、やはりまだまだ自分の解説力にはいたらないところがあるのを痛感しました....。

以下宣伝です。
今回紹介した時計はBOOTHで販売しているので、よかったら購入してみてください。
また、僕は今就活中です。
この記事を読んで僕に興味を持っていただいた企業様がもしいましたら、下記の連絡先までご連絡ください。
Twitter : guilty_vrchat
Gmail : guilty0546@gmail.com

参考文献

ifについて1
ifについて2
ifdefについて
step関数を使って条件分岐について

GuiltyWorks
全て個人的な独り言です。
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした