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

VRChatで現実と同じ月齢の月を実装する

はじめに

VRChatにUdonというプログラミング言語?が実装されました!
2019年12月22日現在まだアルファ版らしいですが、これで今まで以上にUnity標準の機能がいろいろ使えそうですね。
例えばAPIをたたいたりできると今回解説する現実と同じ月齢の月をもっと簡単に実装できそうなので、今後のアップデートが楽しみです。
ということで、今回はVRChatで現実と同じ月を実装したのでその解説をします!

環境

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

どんなものを作ったか

今回は、VRChatで動作する現実と同じ月齢の月を作りました。
ちなみに、月齢には2種類の意味があります。
一つは、赤ちゃんの年齢を月(month)で表したものです。
生後nヶ月なんていったりしますよね、あれです。
もう一つは、月の満ち欠けのことです。
今回はもちろん月の満ち欠けのことです。
なので、現実世界の月が満月だとVRChat世界の月も満月になる、というわけです。

動作は以下の通りです。
VRChat_1920x1080_2019-12-22_01-00-45.175.png

解説

今回作った現実と同じ月齢の月にはRealTimeMoonという名前をつけました。
以降、この名前で呼びます。
RealTimeMoonの概要を説明すると、作業は主に2種類に分けられます。
まず、サーバーからVRC_Panoramaで月齢の情報をエンコードしておとしこんだ画像をダウンロードします。
次に、それをシェーダーでデコードして月齢に反映させます。
それでは、詳しく解説しましょう。

サーバー

まず、サーバーで画像を生成します。
サーバーはNode.jsで動かしているのですが、月齢の計算にはSunCulcというライブラリを使っています。

ちなみに、月齢は以下の式で求めることもできます。
Year年Month月Day日の月齢MoonAge日は、
MoonAge = (((Year - 11) % 19) × 11 + リスト[Month] + Day) % 30

1月 2月 3月 4月 5月 6月 7月 8月 9月 10月 11月 12月
0 2 0 2 2 4 5 6 7 8 9 10

しかし、この計算式は簡易的な計算式なので、数日ずれることがあります。
おそらく、うるう年等が原因でしょう。
そのため、今回はSunCalcライブラリを使いました。

次に、画像ですが、例えば一つのピクセルが保有することができる情報はいくつでしょうか。
答えは、(R, G, B)の3つです。
では、扱える数字(分解能)はいくつになるでしょうか。
(0~255, 0~255, 0~255)でしょうか。
答えは、今回の場合ですと違います。
正解は、(0or1, 0or1, 0or1)です。

なぜなのか解説する前に、別のお話をしましょう。
ぼくは、時計もこの方法でVRChatで実装しています。
しかし、3~5時にかけてなぜか短針が1時間ずれるバグが見つかりました。
なぜでしょうか。
実は、VRC_Panoramaで取得した画像にガンマ補正をかけてあったのです。
ガンマ補正をかけないと、劣化した画像が使われてしまうのです。
じゃあ、ガンマ補正をかけたからもう大丈夫だね、と思いますよね?
しかし、ガンマ補正をかけた画像は実はもとの画像の色とはちょっとだけ違うのです。
つまり、分解能が255ではあっても、ガンマ補正をかけることによって実は分解能はさらに少なくなっていたのです。

そこで、今回は分解能を気にしなくていいように、1ピクセルの持つ情報を(0or1, 0or1, 0or1)にして、月齢の数値を2進数として画像におとしこみました。
これにより、0.5より大きいか小さいかという判定でデジタル的に正確に月齢を取得できるようになりました。

実際に生成した画像は以下の通りです。
※2019年12月22日
RealTimeMoon_25.jpg

けっこう原色感が強いですよね。
理由はもちろん、RGBが上から

R G B
00 FF FF 水色
FF FF FF 白色
00 FF 01 黄緑色

という風にほぼ0or1になっているからです。

シェーダー

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

RealTimeMoon.shader
// 略

fixed4 frag(v2f i) : SV_Target {
    float range = (step(0.5, tex2Dlod(_MainTex, float4(0.5, 0.85, 0, 0)).r) * 256.0 + step(0.5, tex2Dlod(_MainTex, float4(0.5, 0.85, 0, 0)).g) * 128.0 + step(0.5, tex2Dlod(_MainTex, float4(0.5, 0.85, 0, 0)).b) * 64.0 + step(0.5, tex2Dlod(_MainTex, float4(0.5, 0.5, 0, 0)).r) * 32.0 + step(0.5, tex2Dlod(_MainTex, float4(0.5, 0.5, 0, 0)).g) * 16.0 + step(0.5, tex2Dlod(_MainTex, float4(0.5, 0.5, 0, 0)).b) * 8.0 + step(0.5, tex2Dlod(_MainTex, float4(0.5, 0.15, 0, 0)).r) * 4.0 + step(0.5, tex2Dlod(_MainTex, float4(0.5, 0.15, 0, 0)).g) * 2.0 + step(0.5, tex2Dlod(_MainTex, float4(0.5, 0.15, 0, 0)).b) * 1.0) / 10.0;

    float overQuarter = (step(7.5, range % 15.0) * step(range % 15.0, 15.0) - 0.5) * (-2.0);  // -1.0 or 1.0
    float overHalf = step(15.0, range) * step(range, 29.999999);

    range = abs(distance(range % 15, 7.5) - 7.5);

    float theta = UNITY_PI * (range / 15.0);
    float r = 0.25;
    float a = r * cos(theta);
    float inCircle = step(pow(i.originalPos.x, 2.0) / pow(r, 2.0) + pow(i.originalPos.y, 2.0) / pow(r, 2.0), 1.0);
    float dist = distance(acos(((step(0.0, i.originalPos.x) - 0.5) * 2.0) * abs(sqrt(pow(i.originalPos.x, 2.0) / (1.0 - (pow(i.originalPos.y, 2.0) / pow(r, 2.0))))) / r), overQuarter * theta + UNITY_PI * saturate(-overQuarter));
    float check = abs(abs(saturate(step(pow(i.originalPos.x, 2.0) / pow(a, 2.0) + pow(i.originalPos.y, 2.0) / pow(r, 2.0), 1.0) + inCircle * step(overQuarter * i.originalPos.x, 0.0)) - saturate(-overQuarter)) - overHalf + (((overHalf - 0.5) * (-2.0)) * distance(dist, 10.0 * UNITY_PI / 180.0) / (10.0 * UNITY_PI / 180.0)) * step(dist, 10.0 * UNITY_PI / 180.0)) * step(0.0, i.originalPos.z);
    check = inCircle ? check : 0.0;

    half4 tex = texCUBE(_Tex, i.texcoord);
    half3 c = DecodeHDR(tex, _Tex_HDR);
    c = c * _Tint.rgb * unity_ColorSpaceDouble.rgb;
    c *= _MoonExposure * inCircle + _SkyboxExposure * (1.0 - inCircle);

    return float4(lerp(c.r, 0.0, check), lerp(c.g, 0.0, check), lerp(c.b, 0.0, check), 1.0);
}

複雑で説明が難しいので、簡単に説明します。
まず、画像の(R, G, B)からstep関数を使って0か1を判定します。
もちろん、0.5未満だったら0、0.5以上だったら1です。
そして、次に2進数から10進数に変換します。
順番としては、上.r * 256 + 上.g * 128 + 上.b * 64 + 真ん中.r * 32 + 真ん中.g + 16 + 真ん中.b * 8 + 下.r * 4 + 下.g * 2 + 下.b * 1です。
逆の順番にエンコードした場合は逆になるので、サーバー側にあわせます。

次に、月の満ち欠けですが、いい感じに円から楕円を引いたりいろいろしていい感じにします。

余談

今回は月齢をサーバーに計算させましたが、ほかにもいろいろできそうですね。

月の欠けてる部分は黒で塗りつぶしています。
星が見えてしまったら、物理的に月が欠けていることになってしまいます。

月の満ちているところと欠けているところの境目は少しグラデーションをかけています。
おそらく現実世界の月もそうなっているはずなので。

RealTimeMoonシェーダーはSkybox用シェーダーなのですが、SkyboxのマテリアルにVRC_Panoramaを追加することはできないので、実はVRC_Panoramaを追加したQuadにテクスチャを表示させ、それをカメラで読み取ってRenderTextureに出力し、RealTimeMoonマテリアルに入力しています。

おわりに

この記事を書くにあたって、この宇宙を生んでくれたビッグバンに感謝したいと思います。
今回解説したRealTimeMoonはBOOTHで配布中です。

参考文献

月齢 - Wikipedia
SunCalc

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
ユーザーは見つかりませんでした