20200529のUnityに関する記事は8件です。

Unityでオブジェクトのスケールをマイナスにしたらポリゴンが反転するみたいなので調査してみた (Odd Negative Scaling)

概要

Unityでオブジェクトのスケールをマイナスにしたらポリゴンが反転しているのが気になったので、
調査したことを共有しようと思います。

経緯

「変換行列でスケールを反転させてもポリゴンは反転しないよね?」って思ったんですが、
実際に確認したところ、Unityでは反転してるみたいなのでちょっと調査してみることにしました。

動作環境

  • Windows 10
  • Unity 2019.3.14f1
  • UE4

検証パターン

左側がスケールをそのまま、右側はZスケールを-1に設定したものです。
capture_with_inspector.jpg
zreverse_normal.gif
Zスケールを 1 -> -1 にアニメーションさせています。
スケールがマイナスになったときに、ポリゴンが反転していますね。

ポリゴンが反転しない場合のイメージ

※あくまでイメージのために作成した画像です。実際にはこうなりません。
右側のモデルはシェーダーでフロントフェイスカリングにしています。

flipped.png
zreverse_guess.gif

仮説

調査前は以下のように予想してました。

仮説1. ポリゴンを反転させた別モデルを用意して、スケールが負のモデルに使用している

この場合、モデルに対するグラフィックメモリの消費が増えますが、バッチング対象にはなります。

仮説2. フロントフェイスカリングに切り替えている

レンダリング前にGL.invertCulling(true)をコールしているイメージです。
バッチング対象外になるぐらいがデメリットでしょうか。

調査結果

ドキュメントが見つからなかったのでわかりません!

ですが、1つ気になる動きがありました。
Frame Debuggerで「検証パターン」を確認したところ、以下のようなメッセージが表示されていました。

framedebugger.jpg

X, Y, Z スケールのうち奇数個がマイナスの場合は、バッチング対象外になるようですね。
ということで 仮説2 が近いのかなと思いました。

古い資料ですが こちら にも"What breaks batching"の中に"Odd Negative Scaling"とありますね。

結論

詳細は不明だが、UnityはOdd Negative Scalingのオブジェクトの場合、ポリゴンを反転させている。

余談、UE4の場合

じゃあUE4はどうなってるのかな?ってことで調べてみました。

Unreal Editor 4.24.3で確認しました。

Actor(オブジェクト)をOdd Negative Scalingにした場合

反転しました。
ue4_reversed.jpg

親をOdd Negative Scalingにした場合

反転しませんでした。
親のスケールはカリングに影響しない仕様なのかもしれません。

ue4_parent_reversed.jpg

雑感

よくよく考えてみると、スケールが負になったときにポリゴンを反転させた方がいろいろと好都合な気はしました。
ポリゴンを反転させなかったらモデルの見た目がおかしくなるので、反転モデル向けに専用マテリアルが必要になるなど考慮することも多くなりそうですね。

あと、記事を書いていて「ポリゴンの反転」がゲシュタルト崩壊しそうになりました。。。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

UnityでStartCoroutineを実行した際のNullReferenceException対策

UnityでStartCoroutineメソッドを実行した際に、なぜかNullReferenceExceptionのエラーが発生して2時間ほどハマったので、その解決策をメモしておきます。

結論から言うと、Update()メソッド以外のメソッドからコルーチン処理を実行する(StartCoroutineメソッドを実行する)場合は、そのコルーチン処理を呼び出すためのオブジェクトをnewで直接生成してはならないようです。

StartCoroutineの使い方について解説している他の参考サイトなどでは、Update()メソッド内からStartCoroutineを実行している事例しか見当たらなかったため、今回のように他のメソッドから実行したい場合はどうすればよいのか悩みました。

まず、Updateメソッド内からコルーチン処理を呼び出す事例を載せておきます。

EventManager.cs
public class EventManager : MonoBehaviour
{
    void Update()
    {
        //Updateメソッド内からコルーチン呼び出し(この場合は正常に実行できる)
        StartCoroutine("Event");
    }

    //コルーチン関数"Event"を定義
    IEnumerator Event()
    {
        Debug.Log("あいうえお");

        //マウス左クリックを待つ処理
        yield return new WaitUntil(() => Input.GetMouseButtonDown(0));

        Debug.Log("かきくけこ");
    }
}

出力結果を確認すると、正常に実行できていました。

次に、エラーが発生した事例を載せておきます。

EventManager.cs
public class EventManager : MonoBehaviour
{
    public static void CallEvent(string eventName)
    {
        //EventManager のオブジェクトをnewで生成
        EventManager eventManager = new EventManager();
        //コルーチン呼び出し(この場合はエラーが発生する)
        eventManager.StartCoroutine("Event");
    }

    //コルーチン関数"Event"を定義
    IEnumerator Event()
    {
        Debug.Log("あいうえお");

        //マウス左クリックを待つ処理
        yield return new WaitUntil(() => Input.GetMouseButtonDown(0));

        Debug.Log("かきくけこ");
    }
}

今回は、他のクラスからCallEventメソッドを呼び出して"Event"というコルーチン処理を実行しようとしています。しかし、実行結果はNullReferenceExceptionが発生してしまいました。
MonoBehaviour(及びそれを継承したEventManagerクラス)をnewで直接生成しているのがエラーの原因です。

ではどうするのかと言うと、newではなくAddComponentメソッドを使ってオブジェクトを生成します。そのサンプルを載せておきます。

EventManager.cs
public class EventManager : MonoBehaviour
{
    public static void CallEvent(string eventName)
    {
        //AddComponentでオブジェクトを生成
        EventManager eventManager = (new GameObject("適当なオブジェクト名")).AddComponent<EventManager>();
        //コルーチン呼び出し(この場合は正常に実行できる)
        eventManager.StartCoroutine("Event");
    }

    //コルーチン関数"Event"を定義
    IEnumerator Event()
    {
        Debug.Log("あいうえお");

        //マウス左クリックを待つ処理
        yield return new WaitUntil(() => Input.GetMouseButtonDown(0));

        Debug.Log("かきくけこ");
    }
}

はい、これでエラーなく正常に実行できました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Oculus Questで部屋内を動くアプリ作成してみた

はじめに

大学を卒業したばかりの社会人です。
卒業研究でアプリを作ったので何かのお役に立てれば幸いです。
メモ書き感覚なので理解しづらい部分もありますが大目に見てください。
コントローラでの移動や部屋への画面遷移などVRのアプリを作成したい方の手助けができたらなと思います。
初期設定とビルド方法は他の記事を参考にしているのでそちらをご覧ください。(かなり重要です。)
Qiitaに投稿するの初めてなんで優しくしてください。。

環境

・Unity 2018.2.5f1
・Oculus Quest
・Windows 10 Home

作成したアプリの概要

start画面からコントローラのポインタを表示し部屋の雰囲気を選択する画面に遷移、選択した雰囲気の部屋を表示しコントローラでの移動を可能とする。

参考にした記事など

初期設定について
UnityでOculusQuestの開発環境構築

ビルド方法について
Oculus Quest に Unity で開発したアプリをいれる方法(Mac/Win)

機能の追加方法や導入方法
追加方法
※youtubeでPart1~5までありコントローラの表示や動かし方を参考にしました。
英語ですが、Unityでやっていることを見られます。
How to make a VR game in Unity - Part 1 - Setup, Hand presence, Grabbing object
他にも
Oculus QuestでuGUIを操作する

作成目次

1.初期設定
2.画面作成
3.カメラ設定
4.コントローラ設定
5.入力ボタンと画面遷移
6.部屋作成

1.初期設定

Unityで作成したアプリをOculus Questで操作するための初期設定を行います。
上に参考にした記事などがあるので多く省きますが、以下の過程で必要な箇所を説明します。
・Oculus Integrationのインポート
 これはUnityでOculus Questの機能の導入します。
 中にあるOVRPlayerControllerでコントローラの設定など行います。
・UIhelpersの追加
コントローラからレーザを表示し、入力を反映するのに利用します。
 他の記事を参考に作成してください。

2.画面作成

初期画面を作成します。
ここでは2次元での選択画面を作成します。
Sceneを作成し任意のタイトルを付けます。
ゲームオブジェクトからUIのキャンバスを選択し、表示させます。
レンダーモードをワールド空間に変更し、イベントカメラをOVRPlayerController‣OVRCameraRig‣CenterEyeAnchorに指定
スクリーンショット (250).png
これにより2次元での画面を作成し表示させることができる。
テキストやボタンをcanvas内を指定し作る事によりその2次元平面で表示できます。

3.カメラ設定

このアプリはOculus questに作成した画面を表示させます。
初期設定ではMain Cameraがあるためそれを削除します。
OVRPlayerController‣OVRCameraRigがカメラに設定されます。
Scene内で座標や角度を変えることができ表示させる画面に合わせて変更してください。

4.コントローラ設定

ここでは移動方法やポインタ(レーザー)の表示について解説します。
OVRPlayerControllerでは操作している人の動きに合わせて角度が変わり、初付属するスクリプトを設定すれば地面が定められた場所でスティック移動が可能になります。
スクリーンショット (268).png

ポインタを表示方法は上で紹介している動画を参考にしてください。(そっちの方が分かりやすい、、、文章力無くて書くの難しい)

5.入力ボタンと画面遷移

ここでの入力ボタンはcanvas内に二次元のボタンを作成します。
スクリーンショット (253).png

ゲームオブジェクト‣UI‣ボタンを選択し配置したい位置に置きます。
ボタンをポインタ上でクリックした際の動作を付けるにはスクリプトをアタッチします。
ボタンをクリックし別のシーンに移行するスクリプトはこのようになります。
クラス名や読み込むシーンは指定してください。
ここではボタンをクリックするとInputScreenのシーンを読み込む(遷移する)ようにしています。

using UnityEngine;
using UnityEngine.SceneManagement;

public class GameStart : MonoBehaviour
{
    public void OnStartButtonClicked()
    {
        SceneManager.LoadScene("InputScreen");
    }
}

InputScreenでは部屋の印象を選択し、その印象内のイメージを選択して部屋のシーンに遷移します。
印象とイメージはカラーイメージスケールから選定しており、同じ印象では部屋の作りは同一だが、イメージによって色が変わるようになっています。
image1.png
image2.png

印象選択画面のスクリプトは

using UnityEngine;

public class UIManager : MonoBehaviour
{
    //17つのPanelを格納する変数
    //インスペクターウィンドウからゲームオブジェクトを設定する
    [SerializeField] GameObject Select1Panel; //main
    [SerializeField] GameObject Select2Panel; //Casual
    [SerializeField] GameObject Select3Panel; //Natural
    [SerializeField] GameObject Select4Panel; //Dynamic
    [SerializeField] GameObject Select5Panel; //Elegant
    [SerializeField] GameObject Select6Panel; //Chic
    [SerializeField] GameObject Select7Panel; //Pretty
    [SerializeField] GameObject Select8Panel; //Romantic
    [SerializeField] GameObject Select9Panel; //GorGeous
    [SerializeField] GameObject Select10Panel; //Wild
    [SerializeField] GameObject Select11Panel; //Clasic
    [SerializeField] GameObject Select12Panel; //Clear
    [SerializeField] GameObject Select13Panel; //Cool・Casual
    [SerializeField] GameObject Select14Panel; //Modern
    [SerializeField] GameObject Select15Panel; //Dandy
    [SerializeField] GameObject Select16Panel; //Clasic&Dandy
    [SerializeField] GameObject Select17Panel; //Formal

    // Start is called before the first frame update
    void Start()
    {
        //BackToMenuメソッドを呼び出す
        BackToMenu();
    }


    //Select1PanelでButtonaが押されたときの処理
    //Select2Panelをアクティブにする
    //Casual
    public void SelectCasualButton()
    {
        Select1Panel.SetActive(false);
        Select2Panel.SetActive(true);
    }

    //Natural
    public void SelectNaturalButton()
    {
        Select1Panel.SetActive(false);
        Select3Panel.SetActive(true);
    }

    //Dynamic
    public void SelectDynamicButton()
    {
        Select1Panel.SetActive(false);
        Select4Panel.SetActive(true);
    }

    //Elegant
    public void SelectElegantButton()
    {
        Select1Panel.SetActive(false);
        Select5Panel.SetActive(true);
    }

    //Chic
    public void SelectChicButton()
    {
        Select1Panel.SetActive(false);
        Select6Panel.SetActive(true);
    }

    //Pretty
    public void SelectPrettyButton()
    {
        Select1Panel.SetActive(false);
        Select7Panel.SetActive(true);
    }

    //Romantic
    public void SelectRomanticButton()
    {
        Select1Panel.SetActive(false);
        Select8Panel.SetActive(true);
    }

    //GorGeous
    public void SelectGorGeousButton()
    {
        Select1Panel.SetActive(false);
        Select9Panel.SetActive(true);
    }

    //Wild
    public void SelectWildButton()
    {
        Select1Panel.SetActive(false);
        Select10Panel.SetActive(true);
    }

    //Clasic
    public void SelectClasicButton()
    {
        Select1Panel.SetActive(false);
        Select11Panel.SetActive(true);
    }

    //Clear
    public void SelectClearButton()
    {
        Select1Panel.SetActive(false);
        Select12Panel.SetActive(true);
    }

    //Cool
    public void SelectCoolButton()
    {
        Select1Panel.SetActive(false);
        Select13Panel.SetActive(true);
    }

    //Modern
    public void SelectModernButton()
    {
        Select1Panel.SetActive(false);
        Select14Panel.SetActive(true);
    }

    //Dandy
    public void SelectDandyButton()
    {
        Select1Panel.SetActive(false);
        Select15Panel.SetActive(true);
    }

    //Clasic&Dandy
    public void SelectClasicDandyButton()
    {
        Select1Panel.SetActive(false);
        Select16Panel.SetActive(true);
    }

    //Formal
    public void SelectFormalButton()
    {
        Select1Panel.SetActive(false);
        Select17Panel.SetActive(true);
    }

    //BackButtonが押されたときの処理
    //MenuPanelをアクティブにする
    public void BackToMenu()
    {
        Select1Panel.SetActive(true);
        Select2Panel.SetActive(false);
        Select3Panel.SetActive(false);
        Select4Panel.SetActive(false);
        Select5Panel.SetActive(false);
        Select6Panel.SetActive(false);
        Select7Panel.SetActive(false);
        Select8Panel.SetActive(false);
        Select9Panel.SetActive(false);
        Select10Panel.SetActive(false);
        Select11Panel.SetActive(false);
        Select12Panel.SetActive(false);
        Select13Panel.SetActive(false);
        Select14Panel.SetActive(false);
        Select15Panel.SetActive(false);
        Select16Panel.SetActive(false);
        Select17Panel.SetActive(false);
    }
}

このスクリプトではキャンバス内に設定する印象と同じ数のパネルを用意し、選択した印象をクリックすると印象内のイメージを選択する画面に変更します。ここではアクティブの表示設定を変えることにより、画面変更しています。
スクリプトをcanvas内に置き、表示させるパネルを指定します。
各ボタンにクリック時に指定のパネルが表示するようにします。
スクリーンショット (257).png
スクリーンショット (259).png

イメージ選択画面では選択するボタンをクリックすると、部屋の空間に遷移するようにしました。
backボタンを押すと印象選択画面に戻ります。これは上のスクリプトを使っています。
選択したイメージによって同じ部屋でもカラーを変更するためにグローバル変数で数値を定義し、別のシーンでも値を取れるようにしています。
※case1~9まで用意していますが省略しています。
※RGBで数値を変更しますがイメージカラースケールに書いてあるRGBは本などを購入して確認してください。(ここではすべて0の部分)

using UnityEngine;
using UnityEngine.SceneManagement;

public class ChangeCasual : MonoBehaviour
{
    public static byte a1, a2, a3, b1, b2, b3, c1, c2, c3;
    public void ButtonClicked()
    {
        switch (transform.name)
        {
            case "Button1": //派手な 74
                a1 = 0;
                b1 = 0;
                c1 = 0;
                a2 = 0;
                b2 = 0;
                c2 = 0;
                a3 = 0;
                b3 = 0; 
                c3 = 0;
                SceneManager.LoadScene("CasualScene");
                break;
            case "Button9": //気楽な 27
                a1 = 0;
                b1 = 0;
                c1 = 0;
                a2 = 0;
                b2 = 0;
                c2 = 0;
                a3 = 0;
                b3 = 0;
                c3 = 0;
                SceneManager.LoadScene("CasualScene");
                break;
        }
    }
}

スクリーンショット (261).png

スクリーンショット (264).png
スクリーンショット (265).png
同じ部屋の構造でもカラーが異なる。

6.部屋作成

部屋の作成はやり方たくさんあるのですが、アセットストアから持ってきたりもできます。
その場合だとサイズとかバラバラなので動作が重くなることもあります。
自分はplaneで床を作って箱みたいな状態にしています。
家具などはアセットストアから持ってきています。
アセットストア以外にも3D Warehouseでダウンロードしたりしました。
友人はBlenderで扇風機や電気ストーブを作成してました。(余談ですがをこの友人が突っ張り棒をBOOTHで売ってます)

作成した部屋の例を挙げるとこのようになっています。
スクリーンショット (274).png
サイズなどを見やすいようにするとよいと思います。
選択したイメージよって色の変更をするには変更したいオブジェクトにスクリプトをアタッチしました。

using UnityEngine;

public class CasualChange1 : MonoBehaviour {

    void Update () {
        {
            //オブジェクトの色をRGBA値を用いて変更する
            GetComponent<Renderer>().material.color = new Color32(ChangeCasual.a1, ChangeCasual.b1, ChangeCasual.c1, 1);
        }

    }
}

ChangeCasualのスクリプトのa1,b1,c1をつかって色を指定しています。
ここでは一つのスクリプトをですが、印象とイメージにあわせてたくさんのスクリプトを作成しています。

ライトなどの設定は他の記事を見てください。。。(難しいし重くなったりした)

さいごに

読みづらい記事ですね。
動画上げたいんですが、ツイッターやってないので画像いっぱいはりました。
書いた方法以外にもやり方たくさんありますので、試してください。
com.kanade.vrroom-20200529-141210.jpg
com.kanade.vrroom-20200529-141225.jpg
com.kanade.vrroom-20200529-141240.jpg
com.kanade.sample5-20200529-141418.jpg
com.kanade.sample5-20200529-141452.jpg
com.kanade.vrroom-20200529-141305.jpg

大変な時期ですが頑張りましょう。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

多人数リアルタイム通信ゲームの移動処理について考察する

はじめに

VRM使ったリアルタイム通信でなんか面白いこと出来ないか考えているyoship1639です。

リアルタイム通信は以前は低レイヤのAPIを使いやすいように自分でラップしたり、フラグメント化回避のために四苦八苦したり、サーバ~クライアント間の統一したインターフェースをどうやって実装するか悩んだり、シリアライズどうするか絶望したりしていましたが、最近はgRPCがでたり、それを.Netで使いやすいようにラップしたMagicOnionが登場したりで上記の悩みを全部吹き飛ばしてくれる規格やフレームワークが出てきていい時代になったな~と実感しています。

リアルタイム通信は上記の悩みが最初のボトルネックなので、クリアしたら今度はそれを如何に活用できるかが重要になります。例えばチャットアプリは何も考えずにクライアント->サーバ->全クライアントという様にメッセージを流せばいいですが、これが3Dリアルタイム通信ゲームだと話が変わります。

3Dリアルタイム通信ゲームでよくあるのはキャラクタの移動ですが、これをクライアント全てのキャラクタの移動をリアルタイムで同期するにはどうすればいいでしょうか。

今回は、Unityでリアルタイム通信を使った3Dキャラクタの移動処理を実装してみたら意外といい線行ったので、リアルタイム通信特有の問題をどのような考えを元に乗り越え、どの様な実装をしたのかをまとめられればと思います。

多人数リアルタイム通信

多人数のリアルタイム通信の移動処理は簡単にまとめると以下の形が基本形となります

【クライアント】

  • 自キャラ(自クライアントのキャラクタ)を移動させる
  • 一定間隔で自キャラの位置、回転をサーバに送信する
  • 他キャラ(他クライアントのキャラクタ)の位置、回転情報をサーバから受け取り、他キャラのモデルに適用させる

【サーバ】

  • クライアントからキャラクタの位置、回転を受け取る
  • 一定間隔でクライアントに全キャラクタの位置、回転をブロードキャストする

通信処理を記述するなら当たり前のことを綴っているだけですが、実は上記の説明ではリアルタイム通信が抱える問題を密かに解決しています。それは扇問題(正確な名称かは不明)です。扇問題は本記事とは直接的には関係ないので、記事の最後に記載するので解決の詳細を知りたい方は番外編:扇問題の解決を見てください。

さて、他キャラをリアルタイムに同期するときに起こる問題はどの様なことでしょうか。

特有の問題

キャラクタがワープしながら移動する

何も考えずにサーバから受け取った位置、回転の情報を他キャラに反映させると、他キャラはワープしながら移動します。これはサーバから受け取る他キャラ情報がPCのリフレッシュレートよりも遅い時、または一定間隔で受け取れない時に発生します。これは安直にコーディングしたら起こる至極当然の現象なので理解できるかと思います。

分かりやすく仮にコーディングすると以下のようになります。

class CharacterInfo
{
    public Vector3 pos; // キャラクタの位置
    public Quaternion rot; // キャラクタの回転
}

// サーバからキャラクタ情報を受け取る度に呼ばれる
public void OnReceive(CharacterInfo info)
{
    transform.position = info.pos;
    transform.rotation = info.rot;
}

void FixedUpdate()
{
    // 特に何もしない
}

キャラクタが一定のスピードで移動しない①

次に、キャラクタの移動に補間処理を付け加えてみます。他キャラがワープするのを避けたい場合、前回受け取った他キャラ情報からキャラクタの位置、回転を予測しその位置に他キャラを配置する処理を施します。

class CharacterInfo
{
    public Vector3 pos;
    public Quaternion rot;
}

private CharacterInfo prevInfo; // 前回の位置情報
private CharacterInfo nowInfo; // 位置情報
private float time; // 受け取った時点の時間

public void OnReceive(CharacterInfo info)
{
    prevInfo = nowInfo;
    nowInfo = info;
    time = Time.time;
}

void FixedUpdate()
{
    // timeを元にtransformを補間
    var delta = Time.time - time;
    var rate = delta / Time.fixedDeltaTime;
    transform.position = Vector3.Lerp(prevInfo.pos, nowInfo.pos, rate);
    transform.rotation = Quaternion.Slerp(prevInfo.rot, nowInfo.rot, rate);
}

補間処理が記述できているので問題なさそうです。
さて、これがどの様な挙動をするかというと、他キャラは一定速度で移動せず、微妙にブツ切りにワープします。

なぜそのようなことが起こるかというと、サーバから一定間隔で他キャラ情報を受け取っていないからです。この移動処理は一定間隔でサーバから他キャラ情報を受け取ることを前提にしていますが、それは回線やその他の問題で事実上不可能です。なので、一定間隔でサーバから情報を受け取れない事を前提に移動処理を記述しなければなりません。

キャラクタが一定のスピードで移動しない②

先の対応ではキャラクタが一定のスピードで移動してくれない事が分かりました。なので、線形補間を使った滑らかな移動を試してみます。キャラクタの現在位置から目的の位置までを毎フレーム差分時間を使って移動させるやり方です。移動処理だけでなくいろんな場面で滑らかな表現を可能にする手法なので行けそうな気がします。

class CharacterInfo
{
    public Vector3 pos;
    public Quaternion rot;
}

[SerializeField] private float damp = 4.0f; //減衰

private CharacterInfo targetInfo; // 目的の位置情報

public void OnReceive(CharacterInfo info)
{
    targetInfo = info;
}

void FixedUpdate()
{
    transform.position = Vector3.Lerp(transform.position, targetInfo.pos, Time.fixedDeltaTime * damp);
    transform.rotation = Quaternion.Slerp(transform.rotation, targetInfo.rot, Time.fixedDeltaTime * damp);
}

この手法でキャラクタの動きがどの様に見えるかというと、一定速度で動かずガクガクした動きになります。damp(移動減衰値)を下げれば滑らかな動きに多少なりますが、キャラクタの本来の位置と現在位置がかなりずれるため、リアルタイム性が求められるゲームには全く向いていません。


↑ガクガクした動きの例(apngなのでブラウザによってはうまく表示されないかもしれません)

色んな補間のやり方がありますが上記の手法ではどれもうまくいきませんでした。サーバからデータが送られてくる間隔が一定でない場合に他キャラをほとんど遅延なく滑らかに動かすにはどうすれば良いでしょうか。

考えた解決手法

上記の問題を解決するために考えた解決手法は、他キャラが位置情報を送信する時点の時間を同時に送り、サーバから受け取った他キャラ情報をリストに保持し自クライアントの時間と他キャラ情報の時間を比べて位置を補間するというやり方です。分かりやすく解説していきます。

① 他クライアントは送信時に位置情報だけでなく時間も同時に送る

他クライアントは通常位置情報をサーバに送信するだけですが、そこに送信時の時間も載せます。こうする事で、クライアントがサーバから受け取った他キャラ情報がサーバから受け取った時間ではなく他キャラの送信時点の時間になるので、一定時間で受け取らなくても問題にならなくなります。(MagicOnionのhubを使っていますが、ここでは詳細を割愛いたします)

class CharacterInfo
{
    public Vector3 pos;
    public Quaternion rot;
    public float time;
}

IEnumerator UpdateCoroutine()
{
    while(true)
    {
        // サーバにキャラクタの位置、回転、送信時のクライアント時間を送る
        var info = new CharacterInfo()
        {
            pos = player.transform.position,
            rot = player.transform.rotation,
            time = Time.time
        };
        hub.UpdateAsync(info);
        // 0.04秒ごとにサーバに送信する想定
        yield return new WaitForSeconds(0.04f);
    }
}

② サーバから受け取った他キャラ情報をリストに保持する

次に、自クライアントは他キャラ情報をリストに保持していきます。これは、ある一定期間の他キャラ情報が無いと補間が出来なくなるためです。

class CharacterInfo
{
    public Vector3 pos;
    public Quaternion rot;
    public float time;
}

private List<CharacterInfo> infoList = new List<CharacterInfo>();

public void OnReceive(CharacterInfo info)
{
    infoList.Add(info);
}

③ 自クライアントと他クライアントの時間差を考慮する

このままでは、自クライアントの時間と他クライアントの時間に差が出来てしまいうまく位置の補間が出来なくなります。なので、時間差を埋める処理を入れます。

class CharacterInfo
{
    public Vector3 pos;
    public Quaternion rot;
    public float time;
}

private List<CharacterInfo> infoList = new List<CharacterInfo>();
private float fixedTime = 0.0f; // 他キャラ情報の補正時間
private float startTime = 0.0f; // 自クライアントの補正時間
private Vector3 targetPos; // 他キャラの目的の位置

void Start()
{
    // 自クライアントの時間補正も考慮する
    startTime = Time.fixedTime;
}

public void OnReceive(CharacterInfo info)
{
    // 最初に送られてきた他キャラ情報の時間を起点にする
    // こうする事で、送られてきた時点から経過時間を考える事が出来る
    if (fixedTime == 0.0f) fixedTime = info.time;
    info.time -= fixeTime;
    infoList.Add(info);
}

④ 時間から他キャラの位置を補完する

補正時間を考慮した記述で他キャラの位置を割り出します。

class CharacterInfo
{
    public Vector3 pos;
    public Quaternion rot;
    public float time;
}

[SerializeField] private float moveDamp = 12.0f; // 移動減衰

private List<CharacterInfo> infoList = new List<CharacterInfo>();
private float fixedTime = 0.0f; // 他キャラ情報の補正時間
private float startTime = 0.0f; // 自クライアントの補正時間
private int infoListIdx = 0; // 参照するinfoListのindex

void Start()
{
    // 自クライアントの時間補正も考慮する
    startTime = Time.fixedTime;
}

public void OnReceive(CharacterInfo info)
{
    // 最初に送られてきた他キャラ情報の時間を起点にする
    // こうする事で、送られてきた時点から経過時間を考える事が出来る
    if (fixedTime == 0.0f) fixedTime = info.time;
    info.time -= fixeTime;
    infoList.Add(info);
}

void FixedUpdate()
{
    var t = Time.fixedTime - startTime; // 現在の時間
    var i = infoListIdx;
    while (i < infoList.Count - 2)
    {
        // 一定期間内に他キャラがいる
        if (t >= infoList[i].time && t < infoList[i+1].time)
        {
            // 補間時間を割り出す
            var rate = Mathf.InverseLerp(infoList[i].time, infoList[i+1].time, t);
            // 目的位置を割り出す
            targetPos = Vector3.Lerp(infoList[i].pos, infoList[i+1].pos, rate);
            // 回転はここで指定
            transform.rotation = Quaternion.Slerp(infoList[i].rot, infoList[i+1].rot, rate);
            // インデックス指定
            infoListIdx = Mathf.Max(i - 1, 0);
            // 不要な他キャラ情報は削除
            if (infoListIdx > 0) infoList.RemoveAt(0);
            break;
        }
        i++;
    }

    // 滑らかに移動するために固めの線形補間を使う
    transform.position = Vector3.Lerp(transform.position, targetPos, Time.fixedDeltaTime * moveDamp);
}

⑤ レイテンシを考慮する

しかしこのままでは、他クライアント->サーバ->自クライアントに情報が来るまでの遅延を考慮していないので、正しい動作にならない可能性があります。そこで、他クライアントから送られてくる情報の遅延の大きさに関わらずに位置を補正できる様に可変レイテンシを組み込みます。

可変レイテンシを組み込んで細かい調整をした最終的な形はこうなります。

class CharacterInfo
{
    public Vector3 pos;
    public Quaternion rot;
    public float time;
}

[SerializeField] private float moveDamp = 12.0f; // 移動減衰
[SerializeField] private int interval = 2; // infoList参照の広さ

private List<CharacterInfo> infoList = new List<CharacterInfo>();
private float fixedTime = 0.0f; // 他キャラ情報の補正時間
private float startTime = 0.0f; // 自クライアントの補正時間
private int infoListIdx = 0; // 参照するinfoListのindex
private float latency = 0.0f; // 最初は遅延なしで考える

void Start()
{
    // 自クライアントの時間補正も考慮する
    startTime = Time.fixedTime;
}

public void OnReceive(CharacterInfo info)
{
    // 最初に送られてきた他キャラ情報の時間を起点にする
    // こうする事で、送られてきた時点から経過時間を考える事が出来る
    if (fixedTime == 0.0f) fixedTime = info.time;
    info.time -= fixeTime;
    infoList.Add(info);
}

void FixedUpdate()
{
    var t = Time.fixedTime - startTime - latency; // 現在の時間
    var i = infoListIdx;
    while (i < infoList.Count - interval)
    {
        // 一定期間内に他キャラがいる
        var fromInfo = infoList[i];
        var toInfo = infoList[i + interval - 1];
        if (t >= fromInfo.time && t < toInfo.time)
        {
            // 補間時間を割り出す
            var rate = Mathf.InverseLerp(fromInfo.time, toInfo.time, t);
            // 目的位置を割り出す
            targetPos = Vector3.Lerp(fromInfo.pos, toInfo.pos, rate);
            // 回転はここで指定
            transform.rotation = Quaternion.Slerp(fromInfo.rot, toInfo.rot, rate);
            // インデックス指定
            infoListIdx = Mathf.Max(i - 1, 0);
            // 不要な他キャラ情報は削除
            if (infoListIdx > 0)
            {
                infoList.RemoveAt(0);
                // 遅延なく参照できた場合は遅延を少し少なくする
                latency = Mathf.Max(latency - Time.fixedDeltaTime, 0.0f);
            }

            break;
        }
        i++;
    }
    // 上手く参照できなかった場合は遅延を大きくする
    if (i >= infoList.Count - interval) latency += Time.fixedDeltaTime;

    // 滑らかに移動するために固めの線形補間を使う
    transform.position = Vector3.Lerp(transform.position, targetPos, Time.fixedDeltaTime * moveDamp);
}

移動処理だけを重点的に記述しただけなのでこのままでは当然動きませんが、これをベースに動くようにしたら下図の様に他キャラが動くようになります。

いかがでしょう、自分でキャラを動かしている様に見えるくらいには滑らかに動かせているのではないでしょうか。
様々な手法を試して一番良い動きをしたのがこの手法でした。

今回の手法ではキャラクタの動きを予測して移動させる(加速度や入力方向をサーバから受け取って処理する)という事はしていません、予測した動きをするとキャラクタがいきなり早くなったりワープせざる負えない可能性があるからです。もちろん予測した動きを記述したほうが遅延が無いように見せることできますが、そのコードを記述するのには少し時間がかかりそうなので、一先ず今回の手法を提案した次第です。

番外編:扇問題の解決

扇問題とは、クライアントサーバモデルでN個のクライアントの情報をリアルタイムに他のクライアントに反映させたい場合、安直にコーディングすると通信回数が$N * (N-1)$になるという問題です。つまり、クライアント数が増えれば増えるほど、通信回数が爆発的に増えるという事です。

詳しくは以下の記事を参照いただければ分かるかと思います

この問題、安直なコーディングをすると必ず発生してしまうのですが、意外と簡単に回避できます。
それは、サーバからクライアントにデータを送るタイミングを、クライアントからデータを受け取った時ではなく、サーバ主導で一定間隔で送るようにすればいいだけです。クライアントからデータを受け取る度にサーバが処理していたら処理負荷がクライアントの数に引っ張られてしまうので、それを回避するというのが重要となります。

詳しく説明する前にまず、サーバがクライアントからデータを受け取ったタイミングで全クライアントにデータをブロードキャストする場合の通信回数の例を見てみます①。

クライアント数を$N = 100$、クライアントは秒間$T = 20$回データを送信すると仮定すると、1秒間にサーバからクライアントに向けて発生する通信回数は以下の通りとなります。

$N * (N - 1) * T = 100 * 99 * 20 = 198,000$

これでは、通信回数があまりにも多くて負荷が大きすぎるのは目に見えています。

次に、サーバ主導でクライアントにデータをブロードキャストするとどうなるか見てみます②。

先ほどと同じく、$N = 100$、クライアントは秒間$T = 20$回データを送信すると仮定します。そして、サーバは秒間20回全クライアントのデータをまとめて送信する様にします。すると、通信回数は以下の通りになります。

$N * T = 100 * 20 = 2,000$

198,000が2,000まで減りました。約1/100です。少し工夫するだけでこれだけ通信回数が変わりました。
気になるのは通信量です。実は①と②は通信量自体はほぼ変わりません。

クライアントが送信するデータを$D = 28$byte(位置(float x 3) + 回転(float x 4))と仮定すると、①の1秒間の通信量は
99人に28byte送信する処理が100人分あり、それを20回行うので

$(N * (N - 1) * D) * T = 100 * 99 * 28 * 20 = 5,544,000$

で約5.54MBです。

②の1秒間の通信量は
100人に100人分の28byte送信を20回行うので

$N * (N * D) * T = 100 * 100 * 28 * 20 = 5,600,000$

でこちらは約5.6MBとなります。

通信量が変わらないとあまり意味がないと思うかもしれませんが、1 x 10000 と 100 x 100をプログラムが捌くのは明らかに後者の方が速いですし、更に今回はMagicOnionを使っている前提の話なので、内部のデータフォーマッタであるMessagePackによる送信データの圧縮が効きます。それを考えると、②の方が断然効率が良くなります。

キャラクタの位置回転等、秒間20回など高頻度で送信しなければならない場合は②の方が良いですが、例えばキャラクタがアイテムを拾うという処理データを通信するとなると、逆にこちらは①の方が良いです。なぜなら、送信頻度が高くなく連続で送らなくてもいいデータだからです。処理によって住み分けをしっかり行うことが大切になります。

まとめ

3Dリアルタイム通信の移動処理について、うまくいかなかったパターンと解決出来たパターンを紹介いたしました。
勿論、今回考察した移動処理の捌き方が正解というわけではありませんし、問題なく処理できるという保証もありません(実際サーバをクラウドにデプロイして確認したわけではないので…)。なので、1つのやり方としてこんな移動処理の捌き方があるんだという認識をしていただければと幸いです。

この手の記事がほとんど見受けられなかったので、率先して記事にさせていただきました。今回の手法よりもより現実的で実用的な手法があったら是非参考にしたいのでご教授いただければと思います。

権利表記

以下のモデルをお借りして動作確認を行っています。
初音ミク(6)
https://3d.nicovideo.jp/works/td66256
著作: 如月z 様
https://3d.nicovideo.jp/users/56980238

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

螺旋状にオブジェクトを生成する

説明

Unityで螺旋状にオブジェクトを生成します。

前回 は円状にオブジェクトを生成しました。
そのコードから単に、Z軸を少しずつずらしているだけです。

奥行きの距離を表すために、Length設定値を復活させています。
また、位置決めの前に Z軸の 足し算を行っているので、
初期値は1回分足し込み分を引いています。

20180615000750.jpg

数が少ないと、わかりにくいのでオブジェクトを100個生成してみました。

20180615000753.jpg

コード

前回まで Creater って書いていた。。
Creatorでした・・・

using UnityEngine;

public class SpiralObjectCreator : MonoBehaviour {

    [SerializeField]
    private GameObject createObject; // 生成するオブジェクト

    [SerializeField]
    private int itemCount = 100; // 生成するオブジェクトの数

    [SerializeField]
    private float radius = 5f; // 半径

    [SerializeField]
    private float repeat = 5f; // 何周期するか

    [SerializeField]
    private float length = 50f; // Z軸の長さ


    void Start () {

        var oneCycle = 2.0f * Mathf.PI; // sin の周期は 2π
        var oneLength = length / itemCount; // Z軸の1単位
        var z = transform.position.z - oneLength; // Z軸初期位置 (生成前に足しこみをしているので、一回分引いておく)

        for (var i = 0; i < itemCount; ++i)
        {

            var point = ((float)i / itemCount) * oneCycle; // 周期の位置 (1.0 = 100% の時 2π となる)
            var repeatPoint = point * repeat; // 繰り返し位置

            var x = Mathf.Sin(repeatPoint) * radius;
            var y = Mathf.Cos(repeatPoint) * radius;
            z += oneLength;

            var position = new Vector3(x, y, z);

            Instantiate(
                createObject, 
                position, 
                Quaternion.identity, 
                transform
            );

        }

    }

}

GitHub

https://github.com/becky3/unity-test-Spiral

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

円状にオブジェクトを生成する

説明

Unityで円状にオブジェクトを生成します。

前回のサインカーブにオブジェクト生成するものからの発展です。

ちょっとリファクタリングしてますが、yの生成方法は前回と同じです。
さらに、xを同様にCosで求めるだけで、円形になります。

それぞれの周期が半分ずつずれてるので、うまい具合に円になってくれるのですが、
xとyにそれぞれCosとSin与えておけば円になるって覚えとけばなんとかなります。

20180613233826.jpg

あと、距離は円ということで 半径radiusに置き換えました。
円形なので、繰り返しについては正直意味がないんですが、
次の発展絵使えるのでとりあえず残しています。

また、真ん中の球はコピー元のオブジェクトです。
邪魔な場合は、createObjectにPrefabを指定するか、
円を生成後、createObject自体を消滅させても良いでしょう。

20180613234044.jpg

サンプルコード

using UnityEngine;

public class CircleObjectCreater : MonoBehaviour {

    [SerializeField]
    private GameObject createObject; // 生成するオブジェクト

    [SerializeField]
    private int itemCount = 40; // 生成するオブジェクトの数

    [SerializeField]
    private float radius = 5f; // 半径

    [SerializeField]
    private float repeat = 2f; // 何周期するか

    void Start () {

        var oneCycle = 2.0f * Mathf.PI; // sin の周期は 2π

        for (var i = 0; i < itemCount; ++i)
        {

            var point = ((float)i / itemCount) * oneCycle; // 周期の位置 (1.0 = 100% の時 2π となる)
            var repeatPoint = point * repeat; // 繰り返し位置

            var x = Mathf.Cos(repeatPoint) * radius;
            var y = Mathf.Sin(repeatPoint) * radius;

            var position = new Vector3(x, y);

            Instantiate(
                createObject, 
                position, 
                Quaternion.identity, 
                transform
            );

        }


    }

}

GitHub

https://github.com/becky3/unity-test-Circle

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

サインカーブ状にオブジェクトを生成する

説明

Unityでサインカーブ状にオブジェクトを生成します。

20180612232450.jpg

赤線は説明用の手書きです。

sinカーブを2周期で、20個のオブジェクトを生成。
全体の距離が20で、上下の振幅を2倍大げさに見せています。

各オブジェクトの配置間隔は 距離 / オブジェクト数 で求めています。

createObjectに設定したGameObjectを繰り返して貼り付けます。

20180612232453.jpg

パラメータの詳細はコードのコメントを参照してください。

サンプルコード

using UnityEngine;

public class ObjectCreaterCurve : MonoBehaviour {

    [SerializeField]
    private GameObject createObject; // 生成するオブジェクト

    [SerializeField]
    private int itemCount = 20; // 生成するオブジェクトの数

    [SerializeField]
    private float length = 20f; // アイテムの広がる距離

    [SerializeField]
    private float expantion = 2f; // 高さ変動の拡大値

    [SerializeField]
    private float repeat = 2f; // 何周期するか


    void Start () {

        var unit = length / itemCount; // オブジェクトの配置間隔
        var oneCycle = 2.0f * Mathf.PI; // sin の周期は 2π

        for (var i = 0; i < itemCount; ++i)
        {

            var ratio = (float)i / itemCount; // 周期の位置 (1.0 = 100% の時 2π となる)

            var x = i * unit;
            var y = Mathf.Sin(ratio * oneCycle * repeat) * expantion;

            var position = new Vector3(x, y);

            Instantiate(
                createObject, 
                position, 
                Quaternion.identity, 
                transform
            );

        }


    }
}

プロジェクトファイル

gitHubにプロジェクト毎アップロードしています。
https://github.com/becky3/unity-test-SinCurve

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

PUN2のカスタムルームプロパティを変更する

はじめに

Unity使ってゲーム作るぞ~ オンラインゲーム作るぞ~
と意気込んで、さっと調べて目についたPhoton Unity Networking(PUN)でオンライン要素を実装することにしました。

まあ、さっと調べた時に色んなブログさんで参考資料見つけたし、正直余裕でしょ!とか思っていたら微妙なところで躓いたので備忘録として書いておきます。

PUNにはVer 1.~とVer 2.~がありまして、PUNのスタートアップを紹介しているところだとPUN2をダウンロードしましょう!ってあるんですが、実際にPUNでこういうのを実装しました!ってとこだとPUN1のほうってことが結構ありました。
そんでもってPUN1とPUN2でマイナーチェンジがちょこちょこあるもんだから、実際にコード書くと「そんなクラスはない!」だとか「そんなメソッドはない!」って怒られます。

タイトルのカスタムルームプロパティもその類のもので、実装までに時間がかかりました。

カスタムルームプロパティ

using UnityEngine;
using Photon.Pun;

public class RoomProperties : MonoBehaviourPunCallbacks
{
    ExitGames.Client.Photon.Hashtable roomHash;
    float time = 60.0f;

    void Start()
    {
        //ルームプロパティ設定
        roomHash = new ExitGames.Client.Photon.Hashtable();
        roomHash.Add("Time", time);
        PhotonNetwork.CurrentRoom.SetCustomProperties(roomHash);
    }

    void Update()
    {
        time -= Time.deltaTime;
        roomHash["Time"] = time;
        PhotonNetwork.CurrentRoom.SetCustomProperties(roomHash);
    }

    //ルームプロパティが変更されたときに呼ばれる
    public override void OnRoomPropertiesUpdate(ExitGames.Client.Photon.Hashtable propertiesThatChanged)
    {
        object value = null;
        //変更のあったプロパティに"Time"が含まれているならtimeを更新
        if (propertiesThatChanged.TryGetValue("Time", out value))
        {
            time = (float)value;
        }
    }
}

とりあえず注意するところは、
PhotonNetwork.CurrentRoom.SetCustomProperties(roomHash)と
OnRoomPropertiesUpdate(ExitGames.Client.Photon.Hashtable propertiesThatChanged)です。
この2か所がPUN1から変更があるのでうっかりすると、エラーを吐き続けます。

あと、どれだけ値が更新されようがPhotonNetwork.CurrentRoom.SetCustomProperties(ExitGames.Client.Photon.Hashtable HashTable)を実行しないとそのルーム内では共有されません。
更新する値は一つだけということもなく、同時に複数の値を更新した後にSetCustomPropertiesを実行すると変更された値が全て更新されます。
更新するカスタムルームプロパティの値の種類だけ、OnRoomPropertiesUpdateにif文を書いておけばそれぞれが更新されます。

ちなみに、上記のように毎フレーム更新なんてことしたら通信量がガンガン増えるのでやめておいた方がいいです。

さいごに

困ったら公式リファレンスだ!
それでも無理なら現物のコードを追う!!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む