U#でPAPIを作ってみた、ので解説してみる

初めましての人は、初めまして。
こんにちはの人は、こんにちは。
VRChatの零細手打ちUdon屋さん、Lilyです。

VRChat U# でPAPI(滑走路進入角指示灯)を作ってみたので、実装方法と解説を記事にしてみます。

U# ってなに?

という人はこちら。入門記事はこちらがいいかな?
制限付きのC# でUdonが書けます。

PAPIってなに?

という人はこちら。ペンとパインとリンゴとペンじゃないです。憶えてる?
航空関係にいないと、普段目にしないよね……
飛行機飛ばす人たちが、あったら便利と言ってるのを見かけたので、たぶん10番煎じぐらいじゃないかと思うんだけど、作ってみました。
※あと、別に効率が一番いい方法を採ってはないと思う……

・細かいことはいいから完成品はよ!

あっ、はい、こんな感じです。
Boothで頒布してます。

画像2

画像4

標準的な設定が済んでます。
細かい設定方法はReadmeを読むなり、頑張って解読するなり……

・実装論理について

中学か高校の数学が何となくわかっていたら簡単です。
ピタゴラス(三平方)の定理と逆三角関数で出来ています。
雑な図は以下の通り。

画像1

PAPIは、見る人とPAPIの角度(PAPIからの仰角)によって見え方が変わります。
PAPIを中心点に、見る人が地平から何°(上図のθ)にいるかで、赤か、白か、表示を変えてあげればおっけーです。
うぃきぺたんもそう言ってた!

というわけで、PAPIを見る人が地平から何°にいるかを求めるわけですが、角度を求めるときに使うのが逆三角関数です。
今回はarctanを使います。
サインコサインタンジェントの呪文でお馴染みのtanですが、以下の関係が成り立っています。

画像5

この時、h/d=tan(θ)に対して、逆にθ=arctan(h/d)が成り立つ、と決まっています。偉い人が決めました()
なので、角度を求めたいときは、arctanとhとdがあれば良いってことですね!

では、hとdを求めましょう。
hは、見る人とPAPIの高低差ですね。今回はPAPIのある地表と見る人の頭の高さから計算することにしました。差なので、引き算します。簡単ですね。
dは、ピタゴラスの定理で求めます。直角三角形の3辺の関係式ですね。えーじじょうたすびーじじょういこーるしーじじょう……
見る人とPAPIを上から見ると、以下のようになります。二者の距離dは、xとyがあれば出せます。見る人とPAPIのx座標とy座標の差を計算すれば良いです!

画像5

θの計算方法がわかりました!え、たぶん、わかりましたよね……?
後はU# でプログラムを書くだけ(だけ……?)です!

画像6

・U# のソースコードを解説してみる

解説してはみるけど、初心者向けではないです。ある程度プログラムがどういうものか分かってるよ人向けです。
※一応、私はC# ガチ初心者でした。C++は初級、Pythonも初心者です。つまり、美しいソースコードじゃないと思うけどいじめないでください。
Unityでプログラムを動かすには、C# で書かないといけません。U# は、Udon向けのC# 一部機能が使えない版です。

さて、今回頒布しているのが以下のコードです。
20/12/13: IndicatorControllerを指定させるなど、気づいていた欠点を改良しました

/*
* Copyright 2020 Lily at Lily's
*
* This software is released under the MIT License.
* http://opensource.org/licenses/mit-license.php
*/
using UdonSharp;
using UnityEngine;
using VRC.SDKBase;
using VRC.Udon;

public class PAPIController : UdonSharpBehaviour
{
   //indicator
   [SerializeField] IndicatorController[] m_LIndicators;
   //indicator
   [SerializeField] IndicatorController[] m_RIndicators;
   //degree
   [SerializeField] double[] m_Degrees;

   [SerializeField] [ColorUsage(false, false)] Color m_White;
   [SerializeField] [ColorUsage(false, true)] Color m_EWhite;
   [SerializeField] [ColorUsage(false, false)] Color m_Red;
   [SerializeField] [ColorUsage(false, true)] Color m_ERed;

   void Start()
   {
       var dlen = m_Degrees.Length;
       var rads = new double[dlen];

       for(int i = 0; i < dlen; i++){
           rads[i] = m_Degrees[i] * Mathf.Deg2Rad;
       }

       var llen = m_LIndicators.Length;
       for(int i = 0; i < llen; i++){
           m_LIndicators[i].Initialize(this.transform,m_Degrees[i],rads[i],m_White,m_EWhite,m_Red,m_ERed);
       }

       var rlen = m_RIndicators.Length;
       for(int i = 0; i < rlen; i++){
           m_RIndicators[i].Initialize(this.transform,m_Degrees[i],rads[i],m_White,m_EWhite,m_Red,m_ERed);
       }
   }

   
}
/*
* Copyright 2020 Lily at Lily's
*
* This software is released under the MIT License.
* http://opensource.org/licenses/mit-license.php
*/
using UdonSharp;
using UnityEngine;
using VRC.SDKBase;
using VRC.Udon;

public class IndicatorController : UdonSharpBehaviour
{
   [SerializeField] GameObject m_box;

   [SerializeField] MeshRenderer m_light;

   Transform m_base;

   Color m_White;
   Color m_EWhite;
   Color m_Red;
   Color m_ERed;

   double m_rad;

   bool m_isWhite;

   void Start()
   {
       
   }

   void Update()
   {
       if(m_base != null){
           Vector3 base_pos = m_base.position;
           Vector3 player_pos = Networking.LocalPlayer.GetTrackingData(VRCPlayerApi.TrackingDataType.Head).position;
           float d = Mathf.Sqrt(Mathf.Pow(base_pos.x - player_pos.x,2) + Mathf.Pow(base_pos.z - player_pos.z,2));
           float h = Mathf.Abs(base_pos.y - player_pos.y);

           double theta = Mathf.Atan2(h,d);

           bool iswhite = (theta > m_rad);

           if(m_isWhite != iswhite){
               if(iswhite){
                   SetWhite();
               }
               else{
                   SetRed();
               }

               m_isWhite = iswhite;
           }
       }
   }

   public void Initialize(Transform _obj,double _deg,double _rad,Color _w,Color _ew,Color _r,Color _er)
   {
       m_base = _obj;

       m_rad = _rad;
       Vector3 angles = m_box.transform.localEulerAngles;
       angles.x = angles.x - (float)_deg;
       m_box.transform.localEulerAngles = angles;

       m_White = _w;
       m_EWhite = _ew;
       m_Red = _r;
       m_ERed = _er;

       m_isWhite = true;
       SetWhite();
   }
   
   void SetRed(){
       var mat = m_light.material;
       mat.color = m_Red;
       mat.EnableKeyword("_EMISSION");
       mat.SetColor("_EmissionColor", m_ERed);
   }

   void SetWhite(){
       var mat = m_light.material;
       mat.color = m_White;
       mat.EnableKeyword("_EMISSION");
       mat.SetColor("_EmissionColor", m_EWhite);
   }
}

for文など構文から説明を始めると、読む気が失せてしまうほど長くなってしまうので、VRChat/U# 的に気を付けたいところや論理の実装方法について解説しようかと思います。

まずはUnity / C# / U# 的に知っておきたい要素から。

[SerializeField]
この指定をした変数は、Unity上のInspectorで設定可能になります。
U# でデフォルト値を入れておくこともできます。
([SerializeField] bool flag = true;みたいな)
実行時は、Inspectorで設定した値が優先されて、デフォルト値を上書きします。
PAPIContorollerの[SerializeField]指定した変数は、Unity上で次のように見えます。

画像7

GameObject[]のように、[]を付けて、複数指定できる配列にしてね!と書いたものは、Sizeの指定が出て、Sizeに入力した値の分だけ、GameObjectが設定できるようになります。

void Start(){~}
start関数の中に記述した処理は、(たぶん)ワールドの読み込み時に1回だけ実行されます。なので、初期化処理を置くのが良いです。

void Update(){~}
ワールドにいる間、毎フレーム実行されます。60FPSなら1秒間に60回も処理が実行されるので、あまりたくさん処理は書きたくないです。

Vector3
変数の型です。中身としては、小数値をx,y,zで3つ並べたもので、{float x, float y, float z}のようになっています。説明不要かも。
Vector3 vec = {1.1, 2.2, 3.3};とすると、vec.x=1.1です。

あとは処理を追いながら見てみましょう。

構成
下図のイメージ。PAPIControllerは、主にUnityでの設定用です。IndicatorControllerが、各指示灯オブジェクトについていて、実際に指示灯オブジェクトを操作します。

画像8

void PAPIController::Start()
進入角をラジアンに変換した後、Inspectorで設定した値たちを指示灯クラスIndicatorControllerに伝えています。

    void Start()
   {
       var dlen = m_Degrees.Length;
       var rads = new double[dlen];

       for(int i = 0; i < dlen; i++){
           rads[i] = m_Degrees[i] * Mathf.Deg2Rad;
       }

       var llen = m_LIndicators.Length;
       for(int i = 0; i < llen; i++){
           m_LIndicators[i].Initialize(this.transform,m_Degrees[i],rads[i],m_White,m_EWhite,m_Red,m_ERed);
       }

       var rlen = m_RIndicators.Length;
       for(int i = 0; i < rlen; i++){
           m_RIndicators[i].Initialize(this.transform,m_Degrees[i],rads[i],m_White,m_EWhite,m_Red,m_ERed);
       }
   }

dlenはInspectorのDegreesで設定したSizeです。配列.Lengthで取得できます。小数値double配列をradsとして生成し、for文で0番目からSize数分、角度をラジアンに直して格納しています。弧度法で学んだ!ラジアン!
なんでラジアンに直す?だって、arctanの関数がラジアン値返してくるから……

同様に、L/RIndicatorとして設定したオブジェクトを初期設定していきます。
Unity上のInspectorで指定したIndicatorControllerを呼び出しています。IndicatorControllerには、Initializeという関数を作ったので、それを実行して初期設定をします。初期設定なので、1回だけ実行すればいい、ということでStart()に書いてるわけですね。

public void IndicatorController::Initialize()
指示灯オブジェクトが現示色を変更するための情報を与えてあげます。
仰角計算の基準座標、十進度角度、ラジアン角度、白色、発光白色、赤色、発光赤色をPAPIControllerから受け取っています。

    public void Initialize(Transform _obj,double _deg,double _rad,Color _w,Color _ew,Color _r,Color _er)
   {
       m_base = _obj;

       m_rad = _rad;
       Vector3 angles = m_box.transform.localEulerAngles;
       angles.x = angles.x - (float)_deg;
       m_box.transform.localEulerAngles = angles;

       m_White = _w;
       m_EWhite = _ew;
       m_Red = _r;
       m_ERed = _er;

       m_isWhite = true;
       SetWhite();
   }

仰角計算の基準点として、PAPIControllerの座標Transformを渡して、m_baseに記憶します。また、色変更の基準角度として、ラジアン角度もm_radに記憶します。
m_boxにはUnityのInspectorで照明箱を設定していて、角度anglesを十進度角度分マイナス方向に回転させています。何してるかというと、下図の感じ。

画像9

また、各種色情報も記憶させています。
初期設定が終わったら、現状は白(true)をm_isWhiteに設定し、SetWhite関数でとりあえず一旦白色現示にします。

public void SetWhite() / public void SetRed()
m_lightに指定したメッシュのマテリアルを白色/赤色に変更します。
白と赤の色情報はInitializeで記憶しましたね。

    void SetWhite(){
       var mat = m_light.GetComponent<MeshRenderer>().material;
       mat.color = m_White;
       mat.EnableKeyword("_EMISSION");
       mat.SetColor("_EmissionColor", m_EWhite);
   }

MeshRenderer.materialでマテリアル情報を取得、要素の設定ができます。
mat.colorで白色を設定、
mat.EnableKeyword("_EMISSION")でEmissionを有効に、
mat.SetColor("_EmissionColor", m_EWhite)でEmission用白色を設定しています。
これで、発光メッシュが白色を現示します。
赤色の場合も同様。

void IndicatorController::Update()
メインディッシュ。毎フレーム仰角計算をして、結果によって赤白の現示を切り替えています。

   void Update()
   {
       if(m_base != null){
           Vector3 base_pos = m_base.position;
           Vector3 player_pos = Networking.LocalPlayer.GetTrackingData(VRCPlayerApi.TrackingDataType.Head).position;
           float d = Mathf.Sqrt(Mathf.Pow(base_pos.x - player_pos.x,2) + Mathf.Pow(base_pos.z - player_pos.z,2));
           float h = Mathf.Abs(base_pos.y - player_pos.y);

           double theta = Mathf.Atan2(h,d);

           bool iswhite = (theta > m_rad);

           if(m_isWhite != iswhite){
               if(iswhite){
                   SetWhite();
               }
               else{
                   SetRed();
               }

               m_isWhite = iswhite;
           }
       }
   }

base_posがPAPI側基準点の座標、player_posがプレイヤー頭部の座標です。
Networking.LocalPlayer.GetTrackingData(VRCPlayerApi.TrackingDataType.Head).positionで頭部の現在座標が取れます。
dとhの値を計算します。座標の差はマイナスになることがあるので、h算出はMathf.Absを使って絶対値を取っています。また、Unityでは高さ方向がy、奥行き方向がzなので、論理説明の時とy,zの関係が逆です。
arctanの計算には、Mathf.Atan2()を使います。thetaが求めたい仰角θです。

thetaが基準角度m_radより大きければ白色(true)、小さければ赤色(false)とします。
判定結果isWhiteが、現在の現示m_isWhiteと違う結果であれば、SetWhite()/SetRed()で現示色を切り替えます。切り替え終わったら、m_isWhiteを今回の判定結果に上書きします。

この判定が毎フレーム実施され、プレイヤーの頭部位置によって、PAPIが動作することになります。

とりあえず解説としてはこんな感じでしょうか……

長くなりましたが、以上が今回ノリと勢いで作ってみたPAPIの仕組みです。
この記事だけでは説明不十分なところもたくさんあるかと思いますが、そろそろ9500字に達しようかという頃合いなので、切り上げます。

お疲れ様でした。

この記事が気に入ったらサポートをしてみませんか?