Unity
VR
Vtuber
VRコスプレ

UnityでVRコスプレするときに詰まったところの覚え書 その2

この記事は前回の記事の続きです。また自分用メモのように使用しているため、記事内容のブラッシュアップをしていません。

ゆかりさんとVR撮影会

2/13にバーチャルアイドルちゃんねる(VIC) さんで放送しました。
タイムシフトはこちら:http://live.nicovideo.jp/watch/lv311017182

【VR配信】ゆかりさんとVR撮影会【あーかいぶ】
ハッシュタグは#ゆかりさんとXXになりました。
〇ってTwitterのハッシュタグに設定できないんですね。

浮かび上がった問題点

実装・解決したものは打消し線を入れてます。

既存の問題

  1. キャプチャ映像と音がズレている
  2. カメラを持つとブレる
  3. 生放送の管理ができない
  4. デスクトップの解像度が高すぎて文字が読めない
  5. VRIKの再セットアップしたい
  6. 特定のコメントを受信したら色々なモデルを出現させたい
    例えば「ゾウ」と打ったらUnity内にゾウが出現するとか
  7. ボタンを置いていろいろしたい
    カメラの切り替えとかオブジェクトの出現とか再配置とかゲーム画面をデスクトップ画面と置き換えるとか
  8. HTC VIVE と Nintendo Switch のコントローラを同時に持つことが難しい
    腕にマジックテープなどでVIVEのコントローラを固定すれば何とかできるかも?
  9. ユニティちゃん Candy Rock Star ライブステージ!にあるスピーカー実装したい
  10. ゲーム画面を見ている時にゆかりさんが後ろを向いている
    鏡の設置や配信設定を変えることで対応可能?
  11. やっぱりWebCamTexture重い
  12. コメントを落とす範囲が広すぎて取りに行けない
  13. テレポート時に姿勢がおかしくなってる
  14. PCゲーム配信したい
  15. 配信用のオブジェクト配置にするのが面倒
  16. 車のモデル置いてレースクイーンごっことかしたい

新たな問題

  1. やっぱりクライアントが重い(特にCPUパワー)
  2. クッションを動かすとめっちゃ重くなるらしい
  3. 解像度低い状態でもログインできるようにする
  4. ゆかりさんが表示されない場合がある
  5. 操作方法をFPSゲームのようにしてほしい
  6. カメラの移動速度が遅い
  7. 表情変化と指の変化を同時にやるのに苦労する
  8. 表情が切り替わったことがわからない
  9. 最大455メッセージ/秒って多すぎない?
  10. ユーザーネームを入力しないでログインすると固まる
  11. UnityNicoliveClientへ移行したい
  12. 番組開始ボタンと終了ボタンを用意したい
  13. オリジナルの3Dモデルを作りたい
  14. オリジナルのキャラクターモデルを作りたい
  15. Innocenceのライブをやりたい
  16. 花火を作りたい
  17. 屋台を作りたい
  18. VR空間でチェキを撮りたい
  19. SpringBoneの更新をしたい
  20. 広告したらカメラキューブにアイコンを張り付けたい
  21. オブジェクト指向の勉強をし直したい
  22. 非同期処理の勉強をしたい
  23. クライアントをビルドしたときにエラーを吐いた

マルチプレイ時の通信情報

ユーザーネームを入力しないでログインすると固まる

何も入力しないとnullが入ると思っていたけど、実際は空文字(プログラム的に書くと "" )が入っていた。
そのためnullチェックでは足りないことが判明したため、判定を以下のように修正。

Login.cs
/*** 前略 ***/

            //ユーザ名の設定
            if (userName != null && userName != "")
            {
                //ユーザ名を通知
                PhotonNetwork.playerName = userName;
            }
            else
            {
                //プレイヤーの数を取得
                int num = PhotonNetwork.otherPlayers.Length;
                //ユーザ名を生成
                userName = "Guest" + num;
                //ユーザ名を通知
                PhotonNetwork.playerName = userName;
            }

/*** 後略 ***/

最大455メッセージ/秒って多すぎない?

流石にゆかりさんのIK全てにPhotonView付けて同期設定するのは無茶だったと思う。
VRIKで指定しているIKだけ同期するようにスクリプトを以下のように修正

YukariPhoton.cs
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class YukariPhoton : MonoBehaviour
{
    // Use this for initialization
    void Start()
    {
        //ゆかりさんのモデルからVRIKのIK情報を取得
        GameObject yukariObject = GameObject.Find("結月ゆかり_純_ver1.0");
        var vrik = yukariObject.GetComponent<RootMotion.FinalIK.VRIK>();

        //同期するIKのリスト
        List<GameObject> photonList = new List<GameObject>();
        photonList.Add(vrik.references.root.gameObject);
        photonList.Add(vrik.references.pelvis.gameObject);
        photonList.Add(vrik.references.spine.gameObject);
        photonList.Add(vrik.references.chest.gameObject);
        photonList.Add(vrik.references.neck.gameObject);
        photonList.Add(vrik.references.head.gameObject);
        photonList.Add(vrik.references.leftShoulder.gameObject);
        photonList.Add(vrik.references.leftUpperArm.gameObject);
        photonList.Add(vrik.references.leftForearm.gameObject);
        photonList.Add(vrik.references.leftHand.gameObject);
        photonList.Add(vrik.references.rightShoulder.gameObject);
        photonList.Add(vrik.references.rightUpperArm.gameObject);
        photonList.Add(vrik.references.rightForearm.gameObject);
        photonList.Add(vrik.references.rightHand.gameObject);
        photonList.Add(vrik.references.leftThigh.gameObject);
        photonList.Add(vrik.references.leftCalf.gameObject);
        photonList.Add(vrik.references.leftFoot.gameObject);
        photonList.Add(vrik.references.leftToes.gameObject);
        photonList.Add(vrik.references.rightThigh.gameObject);
        photonList.Add(vrik.references.rightCalf.gameObject);
        photonList.Add(vrik.references.rightFoot.gameObject);
        photonList.Add(vrik.references.rightToes.gameObject);

        foreach (GameObject go in photonList)
        {
            var photonView = go.GetComponent<PhotonView>();
            var photonTransformView = go.GetComponent<PhotonTransformView>();

            //位置を同期
            photonTransformView.m_PositionModel.SynchronizeEnabled = true;
            //回転を同期
            photonTransformView.m_RotationModel.SynchronizeEnabled = true;
            //Transformを同期するように設定
            photonView.ObservedComponents[0] = photonTransformView;
            //同期タイミングの設定
            photonView.synchronization = ViewSynchronization.UnreliableOnChange;
        }
    }
}

これだけだともみあげとかうさみみとかフワフワしないけど、ゲスト側でSpringBoneの演算処理をやらせることで解決。
メッセージ数減らしたいからゲスト側に頑張ってもらう方針にしました。

クッションを動かすとめっちゃ重くなるらしい

色々と調査したところ、どうもRigidbodyのUse Gravityがオンになってると重いことが判明しました。
マスターとクライアントで2重に重力処理を行っているから重いのかなと思ったのですが、マスターが居なくても重いので原因は謎です。
まあマスター側で重力演算やって、位置と回転の情報だけ同期してゲスト側に送るように処理してあげましょう。

2018/03/09 追記
Photonにはオブジェクトの所有者という概念があって、クッションなどはマスターが所有者になるように設定してました。
この状態でゲスト側がクッションに対して重力を働かせると、所有していないオブジェクトに対して力を加えることになって挙動がおかしくなるっぽいです。
なのでゲスト側に存在するクッションなどのオブジェクトにはRigidbodyを付けない方が無難だと思います。

2018/03/26 追記
というのは真っ赤な嘘で、単純に Fixed Timestep を小さくしすぎて物理演算にリソースを取られて処理落ちしてただけでした。

解像度低い状態でもログインできるようにする

ログインボタンの位置を中心基準ではなくて画面の下辺基準にすることで対応。

カメラの移動速度が遅い

左シフトキーを押してるときに移動速度と回転速度が4倍になるようにしました。

GuestController.cs
/*** 前略 ***/

        //左シフトキーを押したときのイベント
        if (Input.GetKey(KeyCode.LeftShift))
        {
            coef = 4;
        }
        else
        {
            //押してないときのイベント
            coef = 1;
        }

        //スペースを押したときのイベント
        if (Input.GetKey(KeyCode.E))
        {
            Vector3 vector = go.transform.up * coef * (float)0.025;
            go.transform.position += vector;
        }

/*** 中略 ***/

        //↑を押したときのイベント
        if (Input.GetKey(KeyCode.UpArrow))
        {
            transform.Rotate(-1 * coef, 0, 0);
        }

/*** 後略 ***/

UnityNicoliveClient

ゲスト側でやりますアンコちゃん!に接続する必要はないけど、処理順番的に必ずエラーが吐かれるという気持ち悪い状態だったので、Unityから直接ニコ生APIを叩く方法に変更します。

CommentEvent.cs
using NicoliveClient;
using System;
using System.Collections;
using System.IO;
using System.Linq;
using System.Text;
using UniRx;
using UnityEngine;

public class CommentEvent : MonoBehaviour
{
    //アカウント情報が書いてあるテキストファイルのパス
    public string filePath;

    //デバッグ用に番組IDを指定できるように
    public string streamID;

    //オブジェクトを生成してくれるやつ
    CreateObject createComponent;

    //コメントクライアント
    NicoliveCommentClient commentClient;

    // Use this for initialization
    void Start()
    {
        var account = readFile();

        //アカウント情報がnullでないなら
        if (account != null)
        {
            //コンポーネントを取得
            GameObject createObject = GameObject.Find("CreateObject");
            createComponent = createObject.GetComponent<CreateObject>();

            //コルーチン開始
            StartCoroutine(LoginCoroutine(account[0], account[1]));
        }
    }

    // Update is called once per frame
    void Update()
    {

    }

    //ログインするやつ
    IEnumerator LoginCoroutine(string mail, string pass)
    {
        //ログイン実行
        var u = NiconicoUserClient.LoginAsync(mail, pass).ToYieldInstruction();
        yield return u; //ログイン処理を待機

        //ログインに成功するとユーザ情報が返ってくる
        NiconicoUser user = u.Result;

        //クライアントにユーザ情報を渡して初期化
        var client = new NicoliveApiClient(user);

        //UserAgentを設定する
        //日本語を入力するとバグるから英文字だけにしようね!
        client.SetCustomUserAgent("Yukarisan-XX");

        //番組IDが指定されていなければ放送中のIDを取得
        if (streamID == null || streamID == "")
        {
            Debug.Log("get StreamID");

            //番組ID取得
            var lv = client.GetCurrentNicoliveProgramIdAsync().ToYieldInstruction();
            yield return lv; //番組IDが取得できるまで待機

            //streamIDに記録
            streamID = lv.Result;
        }

        //それでもnullならコルーチンの破棄
        if (streamID == null || streamID == "")
        {
            Debug.Log("StreamID is NULL");
            yield break;
        }

        //操作したい番組ID登録
        client.SetNicoliveProgramId(streamID);

        //コメントを取得するコルーチンをStart
        StartCoroutine(CommentCoroutine(user, client));
    }

    //コメント取得するやつ
    IEnumerator CommentCoroutine(NiconicoUser user, NicoliveApiClient apiClient)
    {
        //番組情報取得
        var pi = apiClient.GetProgramInfoAsync().ToYieldInstruction();
        yield return pi;

        // 番組の部屋一覧
        // 自分が放送する番組の場合は全部屋取得できる
        // 他人の放送の場合は「座席を取得済み」の場合のみ、その座席のある部屋の情報が1つ取得できる
        var rooms = pi.Result.Rooms;

        if (rooms == null)
        {
            Debug.Log("Rooms is NULL");
            yield break;
        }

        //先頭の部屋に接続するコメントクライアントを作成
        commentClient = new NicoliveCommentClient(rooms.First(), user.UserId);

        //コメント購読設定
        commentClient.OnMessageAsObservable.Subscribe((comment) =>
        {
            //Debug.Log(comment.Content);

            //コメントの3Dオブジェクトを生成
            createComponent.CacheComment(comment);
        });

        //クライアント接続
        commentClient.Connect(resFrom: 0);

        /*
        上記プログラムは視聴者数が少ないならこのままでもいいけど、視聴者数が多いなら全部屋のコメントクライアントに接続するように変更しないとダメ
        サンプルの CommentPanel.cs が参考になる
        */

        Debug.Log("コメント取得開始");

        yield break;
    }

    //終了処理
    void OnApplicationQuit()
    {
        Debug.Log("おかたづけ");

        if (commentClient != null)
        {
            //おかたづけ
            commentClient.Disconnect();
            commentClient.Dispose();
        }

    }

    //アカウント情報が書いてあるテキストファイルの読込
    string[] readFile()
    {
        string[] splitDatas = null;

        //ファイル読み込みにトライ
        try
        {
            using (var fileStream = new FileStream(filePath, FileMode.Open))
            {
                using (var streamReader = new StreamReader(fileStream, Encoding.GetEncoding("UTF-8")))
                {
                    //一行ずつ読み込む
                    string line = streamReader.ReadLine();

                    //','区切りで分割したものを配列に追加
                    splitDatas = line.Split(',');
                }
            }
        }
        catch (Exception ex)
        {
            Debug.Log(ex);
        }

        return splitDatas;
    }
}

正直言ってコルーチンの使い方わかってないので、コルーチンの中でコルーチンを呼び出してたりする汚いコードです。しかもReadmeのコピペ。
おまけにエラー処理とかセキュリティの事とか全然考えてないので、実装する場合そこら辺をちゃんと考えてください。
コルーチンについてはココを参考にしました。

問題

  1. 番組情報を取得できない
    最初ユーザエージェントを以下のように設定してました。
CommentEvent.cs
        //UserAgentを設定する
        client.SetCustomUserAgent("ゆかりさんとXX");

これだと番組情報を取得する GetProgramInfoAsync を呼び出した時に、情報が返ってこなくて固まります。
日本語は対応してなく、英文字のみ対応のようなので以下のように修正。

CommentEvent.cs
        //UserAgentを設定する
        //日本語を入力するとバグるから英文字だけにしようね!
        client.SetCustomUserAgent("Yukarisan-XX");
  1. 開始されていない番組のコメントを取得しようとすると固まる
    同期的に処理すべき場所を非同期的に動かしてました。 具体例を挙げると、現在放送中の番組IDを取得するGetCurrentNicoliveProgramIdAsync を呼び出した時に
CommentEvent.cs
        //現在放送中の番組ID取得
        client.GetCurrentNicoliveProgramIdAsync().Subscribe(

        //正常に番組IDが返ってきたとき
        (lv) =>
        {
            Debug.Log("放送中ID取得:" + lv);
            streamID = lv;
        },

        //エラったとき
        (ex) =>
        {
            Debug.LogError(ex);
        });

        //それでもnullならコルーチンの破棄
        if (streamID == null || streamID == "")
        {
            Debug.Log("StreamID is NULL");
            yield break;
        }

という風に書いていた。これだと非同期的に番組IDを取得するため、その後の番組IDチェックが先に判定されてしまってコルーチンがbreakされてました。
同期的に番組IDを取得すれば解決します。

CommentEvent.cs
        //番組ID取得
        var lv = client.GetCurrentNicoliveProgramIdAsync().ToYieldInstruction();
        yield return lv; //番組IDが取得できるまで待機

        //streamIDに記録
        streamID = lv.Result;

非同期処理って難しいですね。スキルのなさを露呈させてしまいました。

表情変化と指の変化を同時にやるのに苦労する

グリップボタンを押しながら指の形を変化させたら、反対側の指を変化させるように機能を追加しました。

ControllerEvents.cs
                //手モーションモード
                case 1:
                    //グリップボタンを押していないなら
                    if (!device.GetPress(SteamVR_Controller.ButtonMask.Grip))
                    {
                        //通常モード
                        changeHand(position);
                    }
                    else
                    {
                        //反対の手を操作するモード
                        isRight = !isRight;
                        changeHand(position);
                        isRight = !isRight;
                    }

                    break;

表情が切り替わったことがわからない

表情変化モードの時にグリップボタンを押したら、自分だけ見える鏡を設置することで解決。
鏡の作り方はココを、最初からfalseなGameObjectを取得する方法はココを参考にしました。

ControllerEvents.cs
/*** 前略 ***/

    GameObject FaceMirror;

/*** 中略 ***/

    // Use this for initialization
    void Start()
    {

/*** 中略 ***/

        //最初からfalseのGameObjectは親から辿っていかないと取得できない
        //https://spphire9.wordpress.com/2013/11/17/unity%E3%81%A7%E3%82%A2%E3%82%AF%E3%83%86%E3%82%A3%E3%83%96%E3%81%A7%E3%81%AA%E3%81%84gameobject%E3%82%92%E5%8F%96%E5%BE%97%E3%81%99%E3%82%8B/
        FaceMirror = GameObject.Find("13.joint_Head").transform.Find("FaceMirror").gameObject;
    }

    // Update is called once per frame
    void Update()
    {

/*** 中略 ***/

        if (device.GetPressUp(SteamVR_Controller.ButtonMask.Grip))
        {
            Debug.Log("グリップボタンをクリックして離した");
            switch (menuState)
            {
                case 2:
                    FaceMirror.SetActive(!FaceMirror.activeSelf);
                    break;

                case 3:
                    GuestController.SendEnd();
                    break;

                default:
                    break;
            }
        }

/*** 後略 ***/

できたもの

オリジナルの3Dモデルを作りたい

VRコンテンツを作っていると必ずぶち当たる壁はやはり3Dモデルだと思います。
私もオリジナルの小物やキャラクターモデルを作りたくて、昔やろうとして諦めたBlenderを改めて勉強することにしました。

左から、MikuMikuDance キャラクターモデルメイキング講座 Pさんが教える3Dモデルの作り方Blender 3Dキャラクター メイキング・テクニックBlender 3DCG モデリング・マスター、です。
左から購入理由を書きますと、MMDの本は手のモデリングが丁寧かつ簡素に書かれていたため、キャラクターメイキングの本はキャラクターをモデリングする技術書として一番読みやすく書いてあったため、3DCGモデリングの本はBlenderの基本操作をモデリングをしながら覚えられる入門書として優秀だったため、それぞれ購入しました。
正直に言ってしまうとMMDの本は読みづらくて時々しか読んでません。小物を作るときは3DCGの本を、キャラクターを作るときはキャラクターメイキングの本を読んでます。

Blenderの小技はココに纏まってる。

できたもの

2018/03/09 までの成果物


2018/03/21 までの成果物


Innocenceのライブをやりたい

Blenderで『あの楽器』を作ったので、Innocenceのライブをしてみたいと思いました。
アセットストアでイイカンジのライブステージを見つけてきたので改造します。

ステージの上に乗るためにワープ方法を高低差を考慮した方法に変更。
VRTK_BasicTeleport から VRTK_HeightAdjustTeleport に変えればいいだけ。詳しくはコチラ

スピーカーから音を出すのにココココを参考にしました。

『あの楽器』を触った位置によって音階が変わるスクリプトを書かないといけない。
触った位置にエフェクトを表示させないといけない。

問題

  1. ライティングとか影とかがおかしくなる
    スポットライトが大量に設置されているアセットなのですが、スポットライトが正しく表示されない(地面いっぱいに明るくなる)問題が発生しました。
    スクリーンショット (106).png
    スクリーンショット (108).png
    1枚目の画像のような描画結果を期待したのに、2枚目のようなのっぺりとした変な描画結果になってしまいました。
    これは SpotLight の RenderMode が Auto になっていたために起きていた問題でした。
    全てのSpotLightのRenderModeを Important にすることで解決。ただし描画コストが高くなることに注意。
    モードの違いによるライティングの変化についてはコチラを参考にしました。
     
    あと地面のマテリアルの設定を変えたらリアルになった。
    スクリーンショット (107).png
    重要なのは Metallic と Smoothness の値。ついでにテクスチャを繰り返すには Tiling の値を適切なサイズに変えればOK。

  2. 影がモデルの根本から離れている
    スクリーンショット (109).png
    期待した描画結果は黄色のライトが生成している影です。こちらはモデルの足元からちゃんと影が描画されています。
    問題は白色のライトが生成している影です。こちらは影が足元から離れてしまっています。
    ライトのBias値をできるだけ下げることで解決。参考はコチラ

  3. ステージに乗れない
    ステージに見立てたPlaneを用意してそこに立ってみます。
    スクリーンショット (110).png
    vlcsnap-2018-03-25-16h32m17s519.jpg
    通常の床はこのように足裏がしっかりと地面に着いているのですが、
    vlcsnap-2018-03-25-16h32m04s853.jpg
    ステージの上にワープすると足がめり込んでしまいます。
    これはVRIKの地面の高さが、VRIKスクリプトをアタッチしたモデルの高さに設定されているためです。
    そのためワープ移動した時にモデルの高さを変更してあげる必要があります。
    Final IK には Grounder という地形に合わせて足を曲げてくれるスクリプトもあるけど、狙っていた効果を発揮してくれなかったので自分でスクリプト書きました。

ModelPositionAdjuster.cs
using UnityEngine;
using VRTK;

public class ModelPositionAdjuster : MonoBehaviour
{
    public Transform modelTransform;
    public Transform cameraRigTransform;
    public GameObject TeleportObject;

    // Use this for initialization
    void Start()
    {
        TeleportObject.GetComponent<VRTK_HeightAdjustTeleport>().Teleported += new TeleportEventHandler(YPosAdjust);
    }

    void YPosAdjust(object sender, DestinationMarkerEventArgs e)
    {
        Vector3 modelPos = modelTransform.position;
        modelPos = new Vector3(modelPos.x, (float)(cameraRigTransform.position.y - 0.01), modelPos.z);
        modelTransform.position = modelPos;
    }
}

適当なオブジェクトにアタッチしたら、モデル と CameraRig の Transform と、VRTK_HeightAdjustTeleport スクリプトがアタッチされているオブジェクトを指定してください。
私の場合はこんな感じ。
スクリーンショット (115).png
これでTeleportするとモデルの位置を適切な位置に移動させることができます。

車のモデル置いてレースクイーンごっことかしたい

アセットストアでイイカンジの車のモデルがあったので、レースクイーンごっことかしてみたいと思います。
ステージはライブステージを流用して、ライティングを工夫します。

問題

  1. 影が描画されない
    Quality Settings で Level を下げていたことが問題でした。参考にしたサイトはコチラ

できたもの

やっぱりクライアントが重い(特にCPUパワー)

ライブステージを追加したらクソ重くなってしまったので、こりゃいかんと真面目に調査することにしました。
とりあえずprofilerを開いて何がボトルネックになっているのかを調べます。
スクリーンショット (117).png
これを見ると Physics.Processing が足を引っ張っていることがわかります。
Physicsというと物理演算の事なので、それ関連で調べたところUnity公式のドキュメントこんな記事が出てきました。
ここで前回の記事を思い返すと、 Fixed Timestep を 0.0001 に設定していました。
もしこのままの設定だと1/10000フレームごとに物理演算が計算されてしまいます。ただでさえ重たい物理演算を1/10000フレームごとにやれとか無茶ですね。
しかも処理落ちをカットする閾値である Maxmium Allowed Timestep を初期値の 0.333・・・ にしていたため、カクつきが激しかったというわけです。
というわけでこれらの設定を以下のように変更しました。
スクリーンショット (118).png
Fixed Timestep を 0.01、つまり1/100フレームごとに、 Maxmium Allowed Timestep はその倍である 0.02 に設定しました。これでカクつきとはおさらばです。
一部環境でゆかりさんが表示されない問題は物理演算が重すぎたのが原因だと思うので、これでゆかりさんが表示されない問題も解決ということにします。

そもそもVR視点でカクついてたから値を小さくしたのであって、ゲストまでする必要ないよね。

クライアントをビルドしたときにエラーを吐いた

ゲスト用のクライアントをビルドしたときに次のようなエラーが吐かれました。

The type or namespace name `UnityEditor' could not be found. Are you missing a using directive or an assembly reference?

コチラの情報で解決しました。
解説すると、これはUnityEditor用のスクリプトがそのままクライアントに残るけどいいのかい?っていうエラーです。
対象のスクリプトをEditorフォルダに入れてあげればOKです。

ゆかりさんとVR撮影会 その2

3/26にバーチャルアイドルちゃんねる(VIC) さんで放送しました。
タイムシフトはこちら:http://live.nicovideo.jp/watch/lv312048608

【VR配信】ゆかりさんとVR撮影会 その2【あーかいぶ】
ハッシュタグは#ゆかりさんとXXです。

浮かび上がった問題点

実装・解決したものは打消し線を入れてます。

既存の問題

  1. キャプチャ映像と音がズレている
  2. カメラを持つとブレる
  3. 生放送の管理ができない
    番組開始ボタンと終了ボタンを用意したい
  4. ボタンを置いていろいろしたい
    カメラの切り替えとかオブジェクトの出現とか再配置とかゲーム画面をデスクトップ画面と置き換えるとか
  5. デスクトップの解像度が高すぎて文字が読めない
  6. VRIKの再セットアップしたい
  7. 特定のコメントを受信したら色々なモデルを出現させたい
    例えば「ゾウ」と打ったらUnity内にゾウが出現するとか
  8. HTC VIVE と Nintendo Switch のコントローラを同時に持つことが難しい
    腕にマジックテープなどでVIVEのコントローラを固定すれば何とかできるかも?
  9. ユニティちゃん Candy Rock Star ライブステージ!にあるスピーカー実装したい
  10. ゲーム画面を見ている時にゆかりさんが後ろを向いている
    鏡の設置や配信設定を変えることで対応可能?
  11. やっぱりWebCamTexture重い
  12. コメントを落とす範囲が広すぎて取りに行けない
  13. テレポート時に姿勢がおかしくなってる
  14. PCゲーム配信したい
  15. 配信用のオブジェクト配置にするのが面倒
  16. 操作方法をFPSゲームのようにしてほしい
  17. オリジナルのキャラクターモデルを作りたい
  18. Innocenceのライブをやりたい
  19. 花火や屋台を作りたい
  20. VR空間でチェキを撮りたい
  21. SpringBoneの更新をしたい
  22. 広告したらカメラキューブにアイコンを張り付けたい
  23. オブジェクト指向の勉強をし直したい
  24. 非同期処理の勉強をしたい

新たな問題

  1. 急にリップシンクが動かなくなった
  2. VR視点でカクつく
    特にゲストが増えると重くなる
  3. 生放送のIDを取りに行くタイミングをゲーム内で任意に指定したい
  4. ステージ切り替えを実装してない
  5. かくれんぼしたい
  6. Unicodeの絵文字(サロゲートペア)を3Dオブジェクトとして出力しようとするとエラーを吐く
  7. コメント3Dオブジェクトを生成したときにたまにラグが発生する
  8. ツイッター連携してハッシュタグに投稿された画像をVR空間に出現させる
  9. VRマルチプレイに対応したい

マルチプレイ時の通信情報

急にリップシンクが動かなくなった

放送を開始してから気づいたのですが、リップシンクが動かなくなっていました。
リップシンクに関しては設定を弄っていなかったため、原因が特定できず難航しましたが、こんな情報を見つけたことで解決しました。
OVRLipSync の DLL関連 でエラーが吐かれてたっぽい?のでスクリプトをアタッチしなおせば直る?という謎仕様。
ちょっとよくわかりません。納得できる解決法でもないのでどなたか教えてください。

2018/04/13 追記:
ようてんさんが原因と解決法を見つけて教えてくださいました。ありがとうございます。


VR視点でカクつく

Fixed Timestep の値を前より大きくしたことでVR視点だとカクつくようになってしまいました。
特にゲストユーザが増えると演算処理が増加するのか処理落ちが激しくなってしまいました。
ゲストクライアントはこのままの設定でいいのですが、マスター側だけ Fixed Timestep の値を変えてあげる必要があります。
スクリプトで動的に変更する方法を模索中。

ボタンを置いていろいろしたい

VRTK_Button というスクリプトをアタッチすればボタンになっちゃうらしい。超簡単。

スクリーンショット (124).png
こんな感じに構成してあげると、
スクリーンショット (123).png
こんな感じの持ち運びできるボタンができます。
VRTKButtonObject には、VRTK_InteractableObject, VRTK_FixedJointGrabAttach, VRTK_SwapControllerGrabAction, Rigidbody をアタッチしてます。
body が下の黒い部分で、名前の最後に Button と付いているオブジェクトがボタン本体です。

問題

  1. ボタンのラベルの文字が他オブジェクトをすり抜けて見えてしまう
    例えば、次のような「Hello World」と書かれた 3D Text が存在するとします。
    このオブジェクトの位置は見てわかるようにゆかりさんやボタンよりも右(奥)になっています。
    スクリーンショット (126).png
    これをゆかりさんの左(手前)から見てみると、体やボタンをすり抜けて見えてしまっています。
    スクリーンショット (125).png
    解決法はココに載ってました。
    序盤は飛ばして「unityのプロジェクトで新しいシェーダーとマテリアルを作って~」からやればOK。
    ここで以前から使っていたメイリオフォントのライセンスについて改めて調べたところ、Unityに埋め込んで使用するのはライセンス違反と判明したため、フォントを変えることにしました。
    アセットストアってフォントも売ってるんですね。今回はフリーのフォントを使用することにします。
    これを行うことで、ちゃんと他オブジェクトで隠れている部分は見えなくなりました。
    スクリーンショット (127).png

  2. ボタンが宙に浮く/落下する/回転する
    Rigidbody で isKinematic に設定しているのに、宙に浮いて行ったり落下したりよくわからない挙動をしてしまいました。
    しかも振り回すとボタンがグルグル回転してしまいます。



    これらは VRTKButtonObject 又は body の片方だけに Rigidbody をアタッチすると起こる現象です。
    両方に Rigidbody をアタッチすれば解決します。
  3. 持ち運んでる最中に荒ぶる
    ボタンを持ち運んでいる時にボタン本体が無重力状態のようになってしまって勝手に押されてしまいます。
    またひっくり返した時にも挙動がおかしくなります。



    この問題はボタン本体と body の間に Collider を入れることで解決。
    ボタン本体を掴んでいる時だけ Collider を消滅させることで挙動がおかしくなるのを防げます。

    解決しませんでした。他にもいろいろ試しましたがことごとく挙動がおかしくなってしまいました。
    結局フラグ管理が一番楽ということになったのであまり価値がないソースコード置いておきます。

    こちらは VRTK_InteractableObject などをアタッチしているGameObjectにアタッチしてください。私の場合は VRTKButtonObject です。
    grabFlag をインスペクタ上で弄られると面倒なのでコチラを参考にして非表示にしました。
VRTKButtonActivationBlocker.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using VRTK;

public class VRTKButtonActivationBlocker : MonoBehaviour
{
    //コンポーネント保持用変数
    VRTK_InteractableObject interactableObject;

    //Grab判定用変数
    [System.NonSerialized]
    public bool grabFlag = false;

    // Use this for initialization
    void Start()
    {
        //コンポーネントを取得
        interactableObject = GetComponent<VRTK_InteractableObject>();

        //掴んだのをやめた時(手を離したとき)にイベントを呼び出すよう設定
        interactableObject.InteractableObjectUngrabbed += (sender, args) =>
        {
            grabFlag = false;
        };

        //掴んだ時にイベントを呼び出すよう設定
        interactableObject.InteractableObjectGrabbed += (sender, args) =>
        {
            grabFlag = true;
        };
    }
}

次にボタン本体にアタッチするスクリプトです。VRTKButtonObject の grabFlag が false の時に、ボタン本体が押されたときにイベントが実行されるようにしています。
親オブジェクトの取得方法はコチラを参考にしました。

VRTKButtonEvent.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using VRTK;
using VRTK.UnityEventHelper;

public class VRTKButtonEvent : MonoBehaviour
{
    //ボタンコンポーネント保持用変数
    VRTK_Button button;

    // Use this for initialization
    void Start()
    {
        //掴んでいる時にボタン判定を消すためのコンポーネントを取得
        VRTKButtonActivationBlocker activationBlocker = transform.parent.GetComponent<VRTKButtonActivationBlocker>();

        //ボタンコンポーネント取得
        button = GetComponent<VRTK_Button>();

        //Pushされたときのイベント追加
        button.Pushed += (object sender, Control3DEventArgs e) =>
        {
            //掴まれていなかったら
            if (!activationBlocker.grabFlag)
            {
                /*** ここに処理内容を記述 ***/
            }
        };
    }
}

ここでVRTKのサンプル25番を見てみると、ボタンを押した時のイベント追加方法が別に用意されてるっぽいです。
というかVRTKのイベント関連全部に用意されています。本当はそっちを使った方がいいと思うのですが、イベントハンドラとラムダ式の勉強のためにこのままにします。

  1. Pushされたときにイベントが飛ばない
    Interact Without Grab が False かつ Connected To に値が入っている時に、ボタン本体をPushしてもイベントが飛ばない。
    スクリーンショット (131).png
    こんな状態の時にイベントが飛びません。
    いろいろと調べたところどうもVRTKのバグっぽいのですが、ver3.3.x α版で VRTK_Button が非推奨になるためバグ修正は行われないっぽいです。
    諦めて Interact Without Grab を True にしましょう。

生放送のIDを取りに行くタイミングをゲーム内で任意に指定したい

ボタンを作ったので、ボタンを押したタイミングで生放送へ接続するようにイベントを仕組みます。
と言っても以前書いた生放送のコメントを取得するスクリプトがほぼ使えます。違うのはイベントの発火タイミングだけです。

NicoLiveConnect.cs
using NicoliveClient;
using System;
using System.Collections;
using System.IO;
using System.Linq;
using System.Text;
using UniRx;
using UnityEngine;
using VRTK;

public class NicoLiveConnect : MonoBehaviour
{
    //アカウント情報が書いてあるテキストファイルのパス
    public string filePath;

    //デバッグ用に番組IDを指定できるように
    public string streamID;

    //オブジェクトを生成してくれるやつ
    CreateFallObjects createFallObjects;

    //コメントクライアント
    NicoliveCommentClient commentClient;

    //コルーチンがRunしてる時のフラグ
    bool coroutineIsRunning = false;

    //ボタンコンポーネント保持用変数
    VRTK_Button button;

    // Use this for initialization
    void Start()
    {
        //niconicoアカウント情報読込
        var account = readFile();

        //落物オブジェクトを生成するコンポーネントを取得
        createFallObjects = GameObject.Find("CreateFallObjects").GetComponent<CreateFallObjects>();

        //掴んでいる時にボタン判定を消すためのコンポーネントを取得
        VRTKButtonActivationBlocker activationBlocker = transform.parent.GetComponent<VRTKButtonActivationBlocker>();

        //ボタンコンポーネント取得
        button = GetComponent<VRTK_Button>();

        //Pushされたときのイベント追加
        button.Pushed += (object sender, Control3DEventArgs e) =>
        {
            //掴まれていなかったら & コルーチンがRunしていないなら & アカウント情報がnullでないなら
            if (!activationBlocker.grabFlag && !coroutineIsRunning && account != null)
            {
                //コルーチン開始
                StartCoroutine(LoginCoroutine(account[0], account[1]));
            }
        };
    }

    //ログインするコルーチン
    IEnumerator LoginCoroutine(string mail, string pass)
    {
        coroutineIsRunning = true;

        //ログイン実行
        var u = NiconicoUserClient.LoginAsync(mail, pass).ToYieldInstruction();
        yield return u; //ログイン処理を待機

        //ログインに成功するとユーザ情報が返ってくる
        NiconicoUser user = u.Result;

        //クライアントにユーザ情報を渡して初期化
        var client = new NicoliveApiClient(user);

        //UserAgentを設定する
        //日本語を入力するとバグるから英文字だけにしようね!
        client.SetCustomUserAgent("Yukarisan-XX");

        //番組IDが指定されていなければ放送中のIDを取得
        if (streamID == null || streamID == "")
        {
            //Debug.Log("get StreamID");

            //番組ID取得
            var lv = client.GetCurrentNicoliveProgramIdAsync().ToYieldInstruction();
            yield return lv; //番組IDが取得できるまで待機

            //streamIDに記録
            streamID = lv.Result;
        }

        //それでもnullならコルーチンの破棄
        if (streamID == null || streamID == "")
        {
            Debug.Log("StreamID is NULL");
            coroutineIsRunning = false;
            yield break;
        }

        //操作したい番組ID登録
        client.SetNicoliveProgramId(streamID);

        //コメントを取得するコルーチンをStart
        StartCoroutine(CommentCoroutine(user, client));
    }

    //コメントを取得するコルーチン
    IEnumerator CommentCoroutine(NiconicoUser user, NicoliveApiClient apiClient)
    {
        //番組情報取得
        var pi = apiClient.GetProgramInfoAsync().ToYieldInstruction();
        yield return pi;

        // 番組の部屋一覧
        // 自分が放送する番組の場合は全部屋取得できる
        // 他人の放送の場合は「座席を取得済み」の場合のみ、その座席のある部屋の情報が1つ取得できる
        var rooms = pi.Result.Rooms;

        if (rooms == null)
        {
            Debug.Log("Rooms is NULL");
            coroutineIsRunning = false;
            yield break;
        }

        //先頭の部屋に接続するコメントクライアントを作成
        commentClient = new NicoliveCommentClient(rooms.First(), user.UserId);

        //コメント購読設定
        commentClient.OnMessageAsObservable.Subscribe((comment) =>
        {
            //Debug.Log(comment.Content);

            //コメントの3Dオブジェクトを生成するためにキャッシュする
            createFallObjects.CacheComment(comment);
        });

        //クライアント接続
        commentClient.Connect(resFrom: 0);

        /*
        上記プログラムは視聴者数が少ないならこのままでもいいけど、視聴者数が多いなら全部屋のコメントクライアントに接続するように変更しないとダメ
        サンプルの CommentPanel.cs が参考になる
        */

        Debug.Log("コメント取得開始 : " + streamID);

        yield break;
    }

    //終了処理
    void OnApplicationQuit()
    {
        //Debug.Log("おかたづけ");

        if (commentClient != null)
        {
            //おかたづけ
            commentClient.Disconnect();
            commentClient.Dispose();
        }
    }

    //アカウント情報が書いてあるテキストファイルの読込
    string[] readFile()
    {
        string[] splitDatas = null;

        //ファイル読み込みにトライ
        try
        {
            using (var fileStream = new FileStream(filePath, FileMode.Open))
            {
                using (var streamReader = new StreamReader(fileStream, Encoding.GetEncoding("UTF-8")))
                {
                    //一行ずつ読み込む
                    string line = streamReader.ReadLine();

                    //','区切りで分割したものを配列に追加
                    splitDatas = line.Split(',');
                }
            }
        }
        catch (Exception ex)
        {
            Debug.Log(ex);
        }

        return splitDatas;
    }
}

オブジェクト指向の勉強をし直したい/非同期処理の勉強をしたい/イベントハンドラをちゃんと理解する/ラムダ式の勉強をしたい/LINQの勉強をしたい/Rxの勉強をしたい

今まで何となくの理解で進めてきたため、しっかりと勉強したいと思います。
ここに資料を列挙しておきますが、ちゃんと勉強するためには本を買った方がいいと思います。
Enumerable メソッド (System.Linq)
関数型プログラミングって何、ラムダってなんだよ
[C#] [ざっくり]イベントハンドラとラムダ式 – gomokulog
[C#] デリゲート(delegate)とはなんぞや – gomokulog
[C#]イベントハンドラとはなんぞや – gomokulog
こわくないReactive Extensions超入門
はじめての LINQ
Unityにおけるコルーチンの性質まとめ
コルーチンの初歩的な使い方【Unity, 初心者】
[Unity]コールチンを止める StopAllCoroutines();のメモ | ちくま倉庫
UnityのCoroutine(コルーチン)でできる事のメモ - テラシュールブログ
UniRx入門 その1
Unity のコルーチンで結果を受け取る←こんなことをするぐらいならUniRxで通知を貰った方がいい(かも)
【初心者向け】変数ではなくプロパティを公開する
C#のプロパティでスタックオーバーフローする話 - かせノート。
C# やるなら LINQ を使おう | プログラマーズ雑記帳
IEnumerable なのに LINQ が使えない!? 使えますよ!
関係ないけどVector3のドキュメントが充実してたのでペタリ
Unity - スクリプトリファレンス: Vector3

コメント3Dオブジェクトを生成したときにたまにラグが発生する

テスト中にコメント3Dオブジェクトを生成したときにたまにラグが発生することに気づきました。
Profilerで負荷がかかっている場所を探してみると、Mesh.Bake という所でした。


これは名前とジャンル的に、MeshCollider の Convex を True にしていたら呼び出される処理っぽいです。
FlyomgText のインスペクタから ColliderType を ConvexMesh から Box に変更することでラグが無くなりました。
スクリーンショット (133).png

ラグの修正ついでにグッチャグチャだったスクリプトの修正を行いました。
コルーチンの勉強のためにコルーチンを使った方法で書いてみました。

CreateFallObjects.cs
using NicoliveClient;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using VRTK;
using VRTK.GrabAttachMechanics;
using VRTK.SecondaryControllerGrabActions;
using VRTK.UnityEventHelper;

public class CreateFallObjects : MonoBehaviour
{
    private GameObject commentObjects = null;
    private Transform commentObjectsTransform = null;
    //private List<Chat> comments = new List<Chat>();
    //private List<string> text = new List<string>();
    //private List<int> no = new List<int>();
    //private List<Vector3> pos = new List<Vector3>();

    private struct Cache
    {
        public List<Chat> chat;
        public List<Vector3> position;
    }

    private Cache cache;

    // Use this for initialization
    void Start()
    {
        //初期化
        cache.chat = new List<Chat>();
        cache.position = new List<Vector3>();

        //キャッシュの残量をチェックするコルーチンを開始
        StartCoroutine(CacheCheckCorourine());
    }

    // Update is called once per frame
    void Update()
    {

    }

    //終了処理
    void OnApplicationQuit()
    {
        //全てのコルーチンを止める
        StopAllCoroutines();
    }

    //コメントをキャッシュする関数
    public void CacheComment(Chat comment)
    {
        //位置の指定
        Vector3 pos;

        //X軸とZ軸だけ Pos をランダム
        if (comment.Content.Length <= 10)
        {
            //10字以下
            pos.x = (float)Random.Range((float)-4.20, (float)2.50);
        }
        else if (comment.Content.Length >= 30)
        {
            //30文字以上
            pos.x = (float)-4.20;
        }
        else
        {
            //10字より多くて30字より少ない
            pos.x = (float)Random.Range((float)-4.20, (float)0.70);
        }
        pos.y = (float)3.00;
        pos.z = (float)Random.Range((float)1.55, (float)2.25);

        //キャッシュに保存
        cache.chat.Add(comment);
        cache.position.Add(pos);
    }

    //キャッシュの残量をチェックして、残量があったらオブジェクトを生成するコルーチンを開始するコルーチン
    IEnumerator CacheCheckCorourine()
    {
        while (true)
        {
            //キャッシュが溜まっていないならスルー
            if (cache.chat.Count <= 0)
            {
                yield return null;
                continue;
            }

            //Create XX ObjectsCoroutine を開始する
            StartCoroutine(CreateTextObjectsCoroutine(cache.chat[0], cache.position[0]));
            StartCoroutine(CreateOtherObjectsCoroutine(cache.chat[0]));

            //キャッシュを削除
            cache.chat.RemoveAt(0);
            cache.position.RemoveAt(0);

            yield return null;
        }
    }

    //コメント3Dオブジェクトを生成するコルーチン
    IEnumerator CreateTextObjectsCoroutine(Chat comment, Vector3 pos)
    {
        //ここにサロゲートペアを判定する処理
        //ここにサロゲートペアを除去する処理

        //文字オブジェクトを作成
        commentObjects = FlyingText.GetObjects(comment.Content);
        if (commentObjects == null)
        {
            yield break;
        }

        //オブジェクト名をコメント番号に設定
        commentObjects.name = "3DText " + comment.No;

        //子が居なくなったらDestroyするスクリプトを追加
        commentObjects.AddComponent<ChildIsZeroDestroy>();

        //文字オブジェクトのTransformを取得
        commentObjectsTransform = commentObjects.transform;
        if (commentObjectsTransform == null)
        {
            yield break;
        }

        //子に必要なコンポーネントをアタッチ
        foreach (Transform child in commentObjectsTransform)
        {
            #region Transform を調整
            //位置をセット
            child.position = pos;

            //次の文字の position を調整
            pos.x += (float)0.2;

            //大きさを 0.1 に指定
            child.localScale = new Vector3((float)0.1, (float)0.1, (float)0.1);
            #endregion

            #region Rigidbodyの設定
            //Rigidbodyを取得
            var rigidbody = child.gameObject.GetComponent<Rigidbody>();

            //質量
            rigidbody.mass = (float)0.001;

            //空気抵抗
            rigidbody.drag = 10;

            //重力を有効化
            rigidbody.useGravity = true;
            #endregion

            //ObjectTimerを追加
            var objectTimer = child.gameObject.AddComponent<ObjectTimer>();

            #region VRTKの設定
            //掴めるようにするため VRTK を追加
            var interactableObject = child.gameObject.AddComponent<VRTK_InteractableObject>();
            interactableObject.isGrabbable = true;
            interactableObject.enabled = true;

            //掴んだのをやめた時(手を離したとき)にイベントを呼び出すよう設定
            interactableObject.InteractableObjectUngrabbed += (sender, args) =>
            {
                if (!rigidbody.isKinematic)
                {
                    //Kinematicを有効化
                    rigidbody.isKinematic = true;
                }
            };

            //掴んだ時にイベントを呼び出すよう設定
            interactableObject.InteractableObjectGrabbed += (sender, args) =>
            {
                if (rigidbody.useGravity)
                {
                    //Gravityを無効化
                    rigidbody.useGravity = false;

                    //オブジェクトをDestroyするタイマーを止める
                    objectTimer.TimerStop();
                }
            };

            //Grabした位置でつかめるようにコンポーネント追加
            var fixedJointGrabAttach = child.gameObject.AddComponent<VRTK_FixedJointGrabAttach>();
            fixedJointGrabAttach.precisionGrab = true;

            //Swapできるようにコンポーネント追加
            child.gameObject.AddComponent<VRTK_SwapControllerGrabAction>();
            #endregion
        }

        yield break;
    }

    //コメントに対応した別オブジェクトを生成するコルーチン
    IEnumerator CreateOtherObjectsCoroutine(Chat comment)
    {
        yield break;
    }


    //コメント主のアイコンを張り付けたCubeを生成する
    IEnumerator CreateIconCubesCoroutine()
    {
        yield break;
    }
}

regionの使い方はコチラを、コルーチンの止め方はコチラを、参考にしました。

さらについでに ObjectTimer も修正しました。

ObjectTimer.cs
using System.Diagnostics;
using UnityEngine;
using System.Collections;

public class ObjectTimer : MonoBehaviour
{

    Stopwatch sw = new Stopwatch();

    // Use this for initialization
    void Start()
    {
        //ストップウォッチを開始
        sw.Start();

        //指定秒数後にストップウォッチをチェックするコルーチンを開始
        StartCoroutine(CheckStopWatch(10));
    }

    // Update is called once per frame
    void Update()
    {

    }

    //指定秒数後にストップウォッチが動いてるかチェックして、動いてたらオブジェクトを破棄するコルーチン
    IEnumerator CheckStopWatch(int destroyTime)
    {
        //指定秒数待機
        yield return new WaitForSecondsRealtime(destroyTime);

        //ストップウォッチが動いているなら
        if (sw.IsRunning)
        {
            //ストップウォッチを止める
            sw.Stop();
            //オブジェクトを破棄
            Destroy(gameObject);
        }

        yield break;
    }

    //ストップウォッチを止める関数
    public void TimerStop()
    {
        sw.Stop();
    }
}

コルーチンで指定秒待機する方法はコチラを参考にしました。

問題

  1. なんかエラー吐いた
    スクリプトの修正を行った後にテストしていたことろ、以下のようなエラーが発生しました。
Assertion failed: Assertion failed on expression: '!m_CoroutineEnumeratorGCHandle.HasTarget()'
UnityEngine.Coroutine:Finalize()

調べてみるとこんな情報が出てきました。
Unity2017.x で起こるUnity側のバグっぽい?発生理由もよくわからないので放置。

こんな情報も見つけました。コルーチンで値を返さない処理をしてしまったときに発生するエラー?

特定のコメントを受信したら色々なモデルを出現させたい

コメント対応表は外部のテキストファイルを読み込んで判定します。
オブジェクトを出現させるのは Photon のゲストカメラを出現させる方法と同じです。
先ほどの CreateFallObjects.cs に追記します。

CreateFallObjects.cs
/*** 前略 ***/

    //キャッシュの残量をチェックして、残量があったらオブジェクトを生成するコルーチンを開始するコルーチン
    IEnumerator CacheCheckCorourine()
    {
        //対応コメントの一覧取得
        Dictionary<string, string> OtherObjectDic = ReadOtherObjectsDic();
        yield return OtherObjectDic;

        while (true)
        {
            //キャッシュが溜まっていないならスルー
            if (cache.chat.Count <= 0)
            {
                yield return null;
                continue;
            }

            //Create XX ObjectsCoroutine を開始する
            StartCoroutine(CreateTextObjectsCoroutine(cache.chat[0], cache.position[0]));
            StartCoroutine(CreateOtherObjectsCoroutine(cache.chat[0], OtherObjectDic));

            //キャッシュを削除
            cache.chat.RemoveAt(0);
            cache.position.RemoveAt(0);

            yield return null;
        }
    }

/*** 中略 ***/

    //コメントに対応した別オブジェクトを生成するコルーチン
    IEnumerator CreateOtherObjectsCoroutine(Chat comment, Dictionary<string, string> otherObjectDic)
    {
        if (otherObjectDic == null)
        {
            yield break;
        }

        foreach (KeyValuePair<string, string> pair in otherObjectDic)
        {
            if (comment.Content.Contains(pair.Key))
            {
                //位置をゆかりさんの真上に設定
                Vector3 pos = yukariTransform.position;
                pos.y += 3;

                // 第1引数にResourcesフォルダの中にあるプレハブの名前(文字列)
                // 第2引数にposition
                // 第3引数にrotation
                // 第4引数にView ID(指定しない場合は0)
                GameObject otherObject = PhotonNetwork.Instantiate(pair.Value, pos, Quaternion.identity, 0);
                if (otherObject == null)
                {
                    yield break;
                }

                otherObject.name = comment.No + " " + pair.Value;

                var objectTimer = otherObject.GetComponent<ObjectTimer>();
                var interactableObject = otherObject.GetComponent<VRTK_InteractableObject>();

                //掴んだ時にイベントを呼び出すよう設定
                interactableObject.InteractableObjectGrabbed += (sender, args) =>
                {
                    //オブジェクトをDestroyするタイマーを止める
                    objectTimer.TimerStop();
                };
            }
        }

        yield break;
    }

    //コメントに対応するオブジェクトのリストを読み込む関数
    Dictionary<string, string> ReadOtherObjectsDic()
    {
        if (otherObjectsDicPath == null || otherObjectsDicPath == "")
        {
            return null;
        }

        string[] lines = null;
        var dictionary = new Dictionary<string, string>();

        try
        {
            //行ごとの配列として、テキストファイルの中身をすべて読み込む
            lines = File.ReadAllLines(otherObjectsDicPath, Encoding.GetEncoding("UTF-8"));

            //全ての行をSplitしてdictionaryにAddする
            foreach (string line in lines)
            {
                var splitStr = line.Split(',');
                dictionary.Add(splitStr[0], splitStr[1]);
            }
        }
        catch (Exception ex)
        {
            Debug.Log(ex);
        }

        return dictionary;
    }

/*** 後略 ***/

文字列中に特定の文字列が存在するか判定する方法はコチラを、ファイルの読込方法についてはコチラを、Dictionaryについてコチラを参考にしました。

できたもの

ゆかりさん(?)とVRおしゃべり会

4/1にニコ生で放送しました。
タイムシフトはこちら:http://live.nicovideo.jp/watch/lv312186214

【VR配信】ゆかりさん(?)とVRおしゃべり会【あーかいぶ】

浮かび上がった問題点

実装・解決したものは打消し線を入れてます。

既存の問題

  1. キャプチャ映像と音がズレている
  2. カメラを持つとブレる
  3. 生放送の管理ができない
    番組開始ボタンと終了ボタンを用意したい
  4. ボタンを置いていろいろしたい
    カメラの切り替えとかオブジェクトの出現とか再配置とかゲーム画面をデスクトップ画面と置き換えるとか
  5. デスクトップの解像度が高すぎて文字が読めない
  6. VRIKの再セットアップしたい
  7. HTC VIVE と Nintendo Switch のコントローラを同時に持つことが難しい
    腕にマジックテープなどでVIVEのコントローラを固定すれば何とかできるかも?
  8. ユニティちゃん Candy Rock Star ライブステージ!にあるスピーカー実装したい
  9. ゲーム画面を見ている時にゆかりさんが後ろを向いている
    鏡の設置や配信設定を変えることで対応可能?
  10. やっぱりWebCamTexture重い
  11. コメントを落とす範囲が広すぎて取りに行けない
  12. テレポート時に姿勢がおかしくなってる
  13. PCゲーム配信したい
  14. 配信用のオブジェクト配置にするのが面倒
  15. 操作方法をFPSゲームのようにしてほしい
  16. オリジナルのキャラクターモデルを作りたい
  17. Innocenceのライブをやりたい
  18. 花火や屋台を作りたい
  19. VR空間でチェキを撮りたい
  20. SpringBoneの更新をしたい
  21. 広告したらカメラキューブにアイコンを張り付けたい
  22. VR視点でカクつく
    特にゲストが増えると重くなる
  23. ステージ切り替えを実装してない
  24. かくれんぼしたい
  25. Unicodeの絵文字(サロゲートペア)を3Dオブジェクトとして出力しようとするとエラーを吐く
  26. ツイッター連携してハッシュタグに投稿された画像をVR空間に出現させる
  27. VRマルチプレイに対応したい
  28. VRMに対応したい

新たな問題

  1. 複数モデルに対応したい
  2. Photonの挙動を理解する
  3. コレやりたい
    ついでにコレもやりたい

複数モデルに対応したい

エイプリルフールネタとして琴葉葵ちゃんになってみました。モデルはお宮式琴葉姉妹モデルをお借りしています。
スクリプトなどはゆかりさんに付いているものと全く同じものを付けます。

問題

  1. 頭の位置が合わない
    既存の HeadTarget はゆかりさん用に調整していたもので、これをそのまま葵ちゃんで使ったら全然ダメでした。
    新しい HeadTarget を作って次のように設定し、これを葵ちゃんの VRIK の HeadTarget に設定することで解決。解決しませんでした。
    モデルの身長差を表現したかったのですが、HeadTarget の調整だけでどうにかできる問題ではなく、モデルのスケールを調整するやり方も正しい方法ではないとわかったので、CameraRig のスケールを調整することで解決しました。
    試しに CameraRig のスケールを5倍にしてみるとフィギュアの部屋を覗いているような視点になります。
    葵ちゃんを調整するついでにゆかりさんも調整しなおします。
ModelChanger.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using VRTK;

public class ModelChanger : MonoBehaviour
{
    //CameraRig 保持用変数
    public Transform cameraRigTransform;

    //モデル保持用変数
    List<GameObject> modelList = new List<GameObject>();

    //モデルインデックス保持用変数
    private int _ModelIndex;
    public int ModelIndex
    {
        get
        {
            return _ModelIndex;
        }

        set
        {
            //ボタンが掴まれていないなら値を代入
            if (!activationBlocker.GrabFlag)
            {
                _ModelIndex = value;
            }
        }
    }

    //スケール保持用変数
    private float _CameraRigScale;
    public float CameraRigScale
    {
        get
        {
            return _CameraRigScale;
        }

        set
        {
            //ボタンが掴まれていないなら値を代入
            if (!activationBlocker.GrabFlag)
            {
                _CameraRigScale = value;
            }
        }
    }

    //PhotonView保持用変数
    PhotonView photonView;

    //コルーチンがRunしてる時のフラグ
    bool coroutineIsRunning = false;

    //activationBlocker保持用変数
    VRTKButtonActivationBlocker activationBlocker;

    // Use this for initialization
    void Start()
    {
        //モデルが格納されているGameObjectを取得
        GameObject models = GameObject.Find("Models");

        //モデルをリストに登録
        foreach (Transform child in models.transform)
        {
            modelList.Add(child.gameObject);
        }

        //PhotonViewを取得
        photonView = GetComponent<PhotonView>();

        //掴んでいる時にボタン判定を消すためのコンポーネントを取得
        activationBlocker = GetComponent<VRTKButtonActivationBlocker>();

        //ボタンコンポーネント取得
        VRTK_Button[] buttons = GetComponentsInChildren<VRTK_Button>();

        //Pushされたときのイベント追加
        foreach (VRTK_Button button in buttons)
        {
            button.Pushed += (object sender, Control3DEventArgs e) =>
            {
                //掴まれていなかったら & コルーチンがRunしていないなら
                if (!activationBlocker.GrabFlag && !coroutineIsRunning)
                {
                    //モデルを切り替えるコルーチンを開始
                    photonView.RPC("ModelChangeCoroutine", PhotonTargets.AllBuffered, ModelIndex);
                    //マスターのスケールを調整するコルーチンを開始
                    photonView.RPC("MasterScaleAdjustCoroutine", PhotonTargets.MasterClient, ModelIndex, CameraRigScale);
                }
            };
        }
    }

    // Update is called once per frame
    void Update()
    {

    }

    //モデルを切り替えるコルーチン
    [PunRPC]
    IEnumerator ModelChangeCoroutine(int index)
    {
        coroutineIsRunning = true;

        Debug.Log("ModelChange : " + modelList[index]);

        //現在アクティブなモデルを探索し、Falseに切り替える
        foreach (GameObject model in modelList)
        {
            if (model.GetActive())
            {
                //モデルを非表示に設定
                model.SetActive(false);
            }
        }

        //指定されたモデルをアクティブに切り替える
        modelList[index].SetActive(true);

        //AutoBlink を開始
        modelList[index].GetComponent<UnityChan.AutoBlink>().IsActive = true;

        coroutineIsRunning = false;
        yield break;
    }

    //マスターのスケールを調整するコルーチン
    [PunRPC]
    IEnumerator MasterScaleAdjustCoroutine(int index, float scale)
    {
        //スケールを指定された値に調整する
        Vector3 vector = new Vector3(scale, scale, scale);
        cameraRigTransform.localScale = vector;

        //モデルの各種操作をするコンポーネントを更新
        ControllerEvents.handController = modelList[index].GetComponent<HandController>();
        ControllerEvents.morphController = modelList[index].GetComponent<MorphController>();
        ControllerEvents.photonView = modelList[index].GetComponent<PhotonView>();

        yield break;
    }
}

これに合わせてAutoBlink.csをちょっと改造しました

AutoBlink.cs
/*** 前略 ***/

        private bool _IsActive;
        public bool IsActive                        //オート目パチ有効
        {
            get
            {
                return _IsActive;
            }

            set
            {
                _IsActive = value;

                //瞬きするなら
                if (_IsActive)
                {
                    // ランダム判定用関数をスタートする
                    PhotonView.RPC("RandomChange", PhotonTargets.All);
                }
            }
        }

/*** 中略 ***/

        //PhotonView保存用変数
        private PhotonView _PhotonView;
        private PhotonView PhotonView
        {
            get
            {
                return _PhotonView ? _PhotonView : (_PhotonView = GetComponent<PhotonView>());
            }
        }

/*** 後略 ***/

このスクリプトをボタンにアタッチしてやって、ボタン本体の Events->OnPush を以下のように設定すればモデルが切り替えられます。
スクリーンショット (140).png

  1. 物をつかんだ時の指の動きができない
    PunRPCを付けた関数はどこからでも呼び出せるものだと思っていましたが、そうではありませんでした。
    目的の関数が記述されてるスクリプトをアタッチした GameObject にある PhotonView を GetComponent しないと使えないようです。この記事を参考にしました。
    というかそもそもの処理が指の localRotation を回転させるやり方だったので、もっとスマートなやり方がないのかと探したところ MMD4Mecanim の作者であるNora様がツイッターでこんなことを呟かれてました。
    どうも各IKに自動的にアタッチされる MMD4MecanimBones.userRotation に値を入れることで回転できるらしいのですが、これってMMDモデルでしか使えない技なのでスルーします。
    とりあえずスクリプトをちょっと修正してお茶を濁しておきましょう。
HandController.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class HandController : MonoBehaviour
{
    //指の状態の総数(デフォルトは除く)
    const int MaxFingerState = 4;

    //右手
    [Tooltip("右手 親指のTransform")]
    public GameObject rightThumbFinger;
    [Tooltip("右手 人差し指のTransform")]
    public GameObject rightIndexFinger;
    [Tooltip("右手 中指のTransform")]
    public GameObject rightMiddleFinger;
    [Tooltip("右手 薬指のTransform")]
    public GameObject rightRingFinger;
    [Tooltip("右手 小指のTransform")]
    public GameObject rightLittleFinger;

    //左手
    [Tooltip("左手 親指のTransform")]
    public GameObject leftThumbFinger;
    [Tooltip("左手 人差し指のTransform")]
    public GameObject leftIndexFinger;
    [Tooltip("左手 中指のTransform")]
    public GameObject leftMiddleFinger;
    [Tooltip("左手 薬指のTransform")]
    public GameObject leftRingFinger;
    [Tooltip("左手 小指のTransform")]
    public GameObject leftLittleFinger;

    //右手用リスト
    //List<Transform> right = new List<Transform>();
    private List<List<GameObject>> rightHand = new List<List<GameObject>>();

    //左手用リスト
    //List<Transform> left = new List<Transform>();
    private List<List<GameObject>> leftHand = new List<List<GameObject>>();

    //右手の掴み判定用変数
    private bool _RisGrab;
    public bool RisGrab
    {
        get
        {
            return _RisGrab;
        }

        set
        {
            _RisGrab = value;

            //掴み始めた時
            if (_RisGrab)
            {
                //指の形状を変化
                photonView.RPC("startGrab", PhotonTargets.All, true);
            }
            //掴むのをやめた時
            else
            {
                //指の状態を復元
                Rstate = Rstate;
            }
        }
    }

    //右手の状態保存用変数
    private int _Rstate;
    public int Rstate
    {
        get
        {
            return _Rstate;
        }

        set
        {
            //もし入力が MaxFingerStateより大きいなら何もしない
            if (value > MaxFingerState)
            {
                return;
            }

            _Rstate = value;

            //もし掴んでいないなら
            if (!RisGrab)
            {
                fingerStateReflection(_Rstate, true);
            }
        }
    }

    //左手の掴み判定用変数
    private bool _LisGrab;
    public bool LisGrab
    {
        get
        {
            return _LisGrab;
        }

        set
        {
            _LisGrab = value;

            //掴み始めた時
            if (_LisGrab)
            {
                //指の形状を変化
                photonView.RPC("startGrab", PhotonTargets.All, false);
            }
            //掴むのをやめた時
            else
            {
                //指の状態を復元
                Lstate = Lstate;
            }
        }
    }

    //左手の状態保存用変数
    private int _Lstate;
    public int Lstate
    {
        get
        {
            return _Lstate;
        }

        set
        {
            //もし入力が fingerStateより大きいなら何もしない
            if (value > MaxFingerState)
            {
                return;
            }

            _Lstate = value;

            //もし掴んでいないなら
            if (!LisGrab)
            {
                fingerStateReflection(_Lstate, false);
            }
        }
    }

    //係数保存用変数
    private int coef;

    //PhotonView保存用変数
    private PhotonView photonView;

    // Use this for initialization
    void Start()
    {
        //PhotonViewを取得
        photonView = GetComponent<PhotonView>();

        //右手を登録
        //0: 親指
        //1: 人差し指
        //2: 中指
        //3: 薬指
        //4: 小指
        rightHand.Add(GetAllChildren.GetAll(rightThumbFinger));
        rightHand.Add(GetAllChildren.GetAll(rightIndexFinger));
        rightHand.Add(GetAllChildren.GetAll(rightMiddleFinger));
        rightHand.Add(GetAllChildren.GetAll(rightRingFinger));
        rightHand.Add(GetAllChildren.GetAll(rightLittleFinger));

        //左手を登録
        //0: 親指
        //1: 人差し指
        //2: 中指
        //3: 薬指
        //4: 小指
        leftHand.Add(GetAllChildren.GetAll(leftThumbFinger));
        leftHand.Add(GetAllChildren.GetAll(leftIndexFinger));
        leftHand.Add(GetAllChildren.GetAll(leftMiddleFinger));
        leftHand.Add(GetAllChildren.GetAll(leftRingFinger));
        leftHand.Add(GetAllChildren.GetAll(leftLittleFinger));
    }

    // Update is called once per frame
    void Update()
    {

    }

    //指の状態を変化させる関数
    private void fingerStateReflection(int state, bool isRight)
    {
        //指の状態を変化
        switch (state)
        {
            case 0:
                photonView.RPC("nomal", PhotonTargets.All, isRight);
                break;
            case 1:
                photonView.RPC("rock", PhotonTargets.All, isRight);
                break;
            case 2:
                photonView.RPC("peace", PhotonTargets.All, isRight);
                break;
            case 3:
                photonView.RPC("paper", PhotonTargets.All, isRight);
                break;
            case 4:
                photonView.RPC("cheat", PhotonTargets.All, isRight);
                break;
            default:
                photonView.RPC("nomal", PhotonTargets.All, isRight);
                break;
        }
    }

    //左右の手を判定して手のリストを返す関数
    private List<List<GameObject>> getHandIK(bool isRight)
    {
        return isRight ? rightHand : leftHand;
    }

    //指を元に戻す
    [PunRPC]
    List<List<GameObject>> nomal(bool isRight)
    {
        List<List<GameObject>> hand = getHandIK(isRight);
        coef = isRight ? 1 : -1;

        //全ての指の回転をリセット
        for (int i = 0; i < 5; i++)
        {
            foreach (GameObject gameObject in hand[i])
            {
                gameObject.transform.localRotation = Quaternion.Euler(0, 0, 0);
            }
        }

        return hand;
    }

    //手をピースに
    [PunRPC]
    void peace(bool isRight)
    {
        //指をリセット
        List<List<GameObject>> hand = nomal(isRight);

        //親指を回転
        hand[0][1].transform.localRotation = Quaternion.Euler(45, coef * 45, 0);
        hand[0][2].transform.localRotation = Quaternion.Euler(45, coef * 45, 0);

        //人差し指を回転
        hand[1][0].transform.localRotation = Quaternion.Euler(0, coef * -10, 0);

        //中指を回転
        hand[2][0].transform.localRotation = Quaternion.Euler(0, coef * 10, 0);

        //薬指と小指を回転
        for (int i = 3; i < 5; i++)
        {
            foreach (GameObject gameObject in hand[i])
            {
                gameObject.transform.localRotation = Quaternion.Euler(0, 0, coef * -75);
            }
        }
    }

    //手をグーに
    [PunRPC]
    void rock(bool isRight)
    {
        //指をリセット
        List<List<GameObject>> hand = nomal(isRight);

        //親指を回転
        hand[0][1].transform.localRotation = Quaternion.Euler(45, coef * 45, 0);
        hand[0][2].transform.localRotation = Quaternion.Euler(45, coef * 45, 0);

        //その他の指を回転
        for (int i = 1; i < 5; i++)
        {
            foreach (GameObject gameObject in hand[i])
            {
                gameObject.transform.localRotation = Quaternion.Euler(0, 0, coef * -75);
            }
        }
    }

    //手をパーに
    [PunRPC]
    void paper(bool isRight)
    {
        //指をリセット
        List<List<GameObject>> hand = nomal(isRight);

        //親指を回転
        hand[0][1].transform.localRotation = Quaternion.Euler(-10, coef * -10, 0);

        //人差し指を回転
        hand[1][0].transform.localRotation = Quaternion.Euler(0, coef * -10, 0);

        //薬指を回転
        hand[3][0].transform.localRotation = Quaternion.Euler(0, coef * 5, 0);

        //小指を回転
        hand[4][0].transform.localRotation = Quaternion.Euler(0, coef * 10, 0);
    }

    //手をグーチョキパー3つ混じったあの形に
    //http://www.irasutoya.com/2017/01/blog-post_2.html
    //フレミング左手の法則に似た形
    [PunRPC]
    void cheat(bool isRight)
    {
        //指をリセット
        List<List<GameObject>> hand = nomal(isRight);

        //親指を回転
        hand[0][1].transform.localRotation = Quaternion.Euler(-10, coef * -10, 0);

        //人差し指を回転
        hand[1][0].transform.localRotation = Quaternion.Euler(0, coef * -10, 0);

        //中指を回転
        hand[2][0].transform.localRotation = Quaternion.Euler(0, coef * 10, 0);

        //薬指と小指を回転
        for (int i = 3; i < 5; i++)
        {
            foreach (GameObject gameObject in hand[i])
            {
                gameObject.transform.localRotation = Quaternion.Euler(0, 0, coef * -75);
            }
        }
    }

    //物を掴んている形に
    [PunRPC]
    void startGrab(bool isRight)
    {
        //指をリセット
        List<List<GameObject>> hand = nomal(isRight);

        //親指を回転
        hand[0][0].transform.localRotation = Quaternion.Euler(0, 0, coef * -20);
        hand[0][1].transform.localRotation = Quaternion.Euler(20, coef * 20, 0);
        hand[0][2].transform.localRotation = Quaternion.Euler(20, coef * 20, 0);

        //その他の指を回転
        for (int i = 1; i < 5; i++)
        {
            foreach (GameObject gameObject in hand[i])
            {
                gameObject.transform.localRotation = Quaternion.Euler(0, 0, coef * -30);
            }
        }
    }
}

また MMD4Mecanim のコンバートオプションとして大量の機能があることも知りました。
MMD4Mecanim コンバートオプション活用のススメ
次は光物の表現をやってみたいですね。

  1. 表情の管理ができない
    モーフの操作をゆかりさん用に調整していたので、これをそのまま葵ちゃんで使っても(当たり前ですが)動きませんでした。
    解決法はスクリプトをどんなモデルにも使えるように調整するか、MMD4MecanimFaciem という素晴らしいアセットがあるのでこちらに切り替えるかのどちらかです。
    MMD4MecanimFaciem はGUIで表情の管理ができる便利なアセットなのですが、これもMMDモデルでしか使用できません。
    将来的にMMDモデル以外のモデルも導入する予定なので、このアセットを使用するのはやめておきましょう。自作のスクリプトを改修していきます。
MorphController.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class MorphController : MonoBehaviour
{
    //モーフの状態の総数(デフォルトは除く)
    const int MaxMorphState = 4;

    //表情モーフの参照
    public SkinnedMeshRenderer skinnedMeshRenderer;

    //表情モーフのインデックスリスト
    public List<int> smileMorphList;
    public List<int> angryMorphList;
    public List<int> leftWinkMorphList;
    public List<int> rightWinkMorphList;

    //モーフステート保存用変数
    private int _MorphState;
    public int MorphState
    {
        get
        {
            return _MorphState;
        }

        set
        {
            //もし入力が MaxMorphStateより大きいなら何もしない
            if (value > MaxMorphState)
            {
                return;
            }

            _MorphState = value;

            //モーフステートが 0ならデフォルトに設定
            if (_MorphState == 0)
            {
                //モーフをデフォルトに設定
                photonView.RPC("setDefault", PhotonTargets.All, true);
            }
            //モーフステートが 0以外ならそのモーフリストを設定
            else
            {
                //モーフを指定のモーフリストに設定
                photonView.RPC("setOtherMorph", PhotonTargets.All);
            }
        }
    }

    //PhotonView保存用変数
    private PhotonView photonView;

    //AutoBlink保存用変数
    private UnityChan.AutoBlink autoBlink;

    //全てのモーフリストを格納する変数
    private List<List<int>> allMorphList = new List<List<int>>();

    // Use this for initialization
    void Start()
    {
        //各種コンポーネントを取得
        photonView = GetComponent<PhotonView>();
        autoBlink = GetComponent<UnityChan.AutoBlink>();

        //全ての表情モーフリストをまとめる
        allMorphList.Add(smileMorphList);
        allMorphList.Add(angryMorphList);
        allMorphList.Add(leftWinkMorphList);
        allMorphList.Add(rightWinkMorphList);
    }

    // Update is called once per frame
    void Update()
    {

    }

    //無表情へ変化
    [PunRPC]
    IEnumerator setDefault(bool blinkActive)
    {
        //全てのモーフの値が0になったらループから脱出
        bool whileFlag = true;
        while (whileFlag)
        {
            //フラグ用変数
            float allWeight = 0;
            //モーフリストのウェイトを取り出して新しい値をセット
            foreach (List<int> list in allMorphList)
            {
                foreach (int id in list)
                {
                    //ウェイトを取得して-50する
                    float weight = skinnedMeshRenderer.GetBlendShapeWeight(id) - 50;
                    //もしモーフの値が 0以下なら 0にセット
                    weight = weight <= 0 ? 0 : weight;
                    //ウェイトを設定
                    skinnedMeshRenderer.SetBlendShapeWeight(id, weight);
                    //フラグ用変数にウェイトの値を加算
                    allWeight += weight;
                }
            }

            //フラグの値を更新
            //全てのウェイト値の合計が 0なら、全てのモーフがリセットされたとしてフラグをFalseに設定
            whileFlag = allWeight == 0 ? false : true;

            //0.01秒待機
            yield return new WaitForSecondsRealtime((float)0.01);
        }

        //瞬きの動作をセット
        autoBlink.IsActive = blinkActive;

        yield break;
    }

    //その他の顔へ変化
    [PunRPC]
    IEnumerator setOtherMorph()
    {
        //既存の表情を無効化
        yield return StartCoroutine(setDefault(false));

        //目的のモーフの値が全て100になったらループから脱出
        bool whileFlag = true;
        while (whileFlag)
        {
            //フラグ用変数
            float allWeight = 0;
            //モーフリストのウェイトを取り出して新しい値をセット
            foreach (int id in allMorphList[MorphState - 1])
            {
                //ウェイトを取得して+50する
                float weight = skinnedMeshRenderer.GetBlendShapeWeight(id) + 50;
                //もしモーフの値が 100以上なら 100にセット
                weight = weight >= 100 ? 100 : weight;
                //ウェイトを設定
                skinnedMeshRenderer.SetBlendShapeWeight(id, weight);
                //フラグ用変数にウェイトの値を加算
                allWeight += weight;
            }

            //フラグの値を更新
            //目的のウェイト値の合計が (指定のモーフの数 * 100) なら、指定のモーフに値がセットされたとしてフラグをFalseに設定
            whileFlag = allWeight == (allMorphList[MorphState - 1].Count) * 100 ? false : true;

            //0.01秒待機
            yield return new WaitForSecondsRealtime((float)0.01);
        }

        yield break;
    }
}

既存の FaceController を大幅に改修しました。本当はエディタ拡張まで書いて表情を追加できるようにしたかったのですが、よくわからなかったので諦めました。
一応表情モーフのインデックスリストを追加すれば表情を追加できるようになってます。

  1. リップシンクが遅れる
    葵ちゃんのリップシンクだけコンマ数秒遅れてしまって違和感バリバリな状態になってしまいました。
    モデルをいったん非表示にして再度表示するとラグがなくなっていたので、ゆかりさんのモデルを最初に表示しておいて、後から切り替えることで対応できそうです。
    というかモデルを切り替えるボタンを実装したのでそれでいいような気がします。

Photonの挙動を理解する

資料だけ残しておきます。
ドキュメント一覧 – Photon
【Photon】RPCの使い方
Photonを使ってネットワーク同期させる - e.blog
【Unity】僕もPhotonを使いたい #07 位置の同期 - うら干物書き
【Unity】僕もPhotonを使いたい #08 RPC() PhotonTargets編 - うら干物書き
【Unity】僕もPhotonを使いたい #01 たくさんのConnect() - うら干物書き
【Unity、PUN】Photon Unity Networkingのコールバックメソッド一覧 | naichilab - Android iOSアプリ開発メモ
パフォーマンスのヒント | Photon Engine
Unityネットワーク通信の基盤である「RPC」について、意外と知られていないボトルネックと、その対策法

Unicodeの絵文字(サロゲートペア)を3Dオブジェクトとして出力しようとするとエラーを吐く

資料だけ残しておきます。
[C#] サロゲートペアを含む文字列で文字を個別に取り扱う
サロゲートペアや結合文字が含まれているか調べる - .NET Tips (VB.NET,C#...)
[雑記] C# ソースコードと Unicode - C# によるプログラミング入門 | ++C++; // 未確認飛行 C

SpringBoneの更新をしたい

2017年に公開された UnityChan The Phantom Knowledge のプロジェクトデータで、SpringBoneが更新されたという情報を頂きました。
少し触ってみたところ、使い勝手が大幅に改善されていてとても良いものになっていたので、古いSpringBoneを全て新しいものに置き換えたいと思います。
プロジェクトデータはコチラからDLします。全部入りデータである『The Phantom Knowledge Project Data』にはSpringBoneが入ってないので注意。『制服ユニティちゃん』をDLしましょう。

Vive Tracker 2018 が手に入るまで延期。 VRMモデル用のSpringBoneが公開されました。そちらに移行することにします。

Innocenceのライブをやりたい

無期限延期 資料だけ残しておきます。
Unity パーティクル(3) 衝撃・打撃エフェクト - LemonteaのUnity部屋
Unityでコライダ1つだけでダメージ箇所を特定する | Unityを使った3Dゲームの作り方(かめくめ)
[Unity]タップして波紋エフェクトを出す手順
【Unity】タップした位置にエフェクトを表示する - テラシュールブログ
UnityのTransformのワールド空間とローカル空間について | Unityを使った3Dゲームの作り方(かめくめ)
Unityのレイキャストとは何ぞや?(自分用)
【Unity】音を鳴らす:「はじめてのUnity」のブロック崩しを改造しながら学ぶ

VRマルチプレイに対応したい

2018/04/13 に VirtualCast が発表されました。
基本的にはみゅみゅさんの生放送用システムを改良して一般向けに公開されたもので、後日配布されるであろうSDKの拡張性が高ければ私のように自力でVRコスプレ(VTuber)システムを構築しなくてもいいようになります。
VRマルチプレイが一般化してしまったので私の方でも急いで実装することにしました。

  1. ボイスチャットの実装
    Photon Voiceを使用してボイスチャットを実装します。
    AssetStoreからインポートしてきたら、サンプルを見ながら必要なコンポーネントをアタッチします。
    そして Window->Photon Unity Networking->Highlight Server Setting に Photon Voice 用の AppID を入力すれば使えるようになります。
    ここで注意が必要なのが、マイクの選択ができないということです。もしマイク入力を変えたい場合はスクリプトを弄る必要があるでしょう。
    詳細は別記事にて書きます。暫くお待ちください。

  2. リップシンクの実装
    Photon Voice の出力に同期してリップシンクをさせるには、 OVRLipSyncMicInput を無力化させればいい(?)

  3. 落物オブジェクトの操作
    位置を同期するだけでなく、持てるようにするにはちょっと工夫が必要?
    下手に実装すると同期ずれが激しくなる。

問題

VRMに対応したい

『VRマルチプレイに対応したい』と同時進行で対応していきます。
ドワンゴが2018/04/16に VRM という規格を発表・公開しました。
『VRアプリケーション向けの人型3Dアバター(3Dモデル)データを扱うためのファイルフォーマット』であり、どんなアプリケーションでも簡単にオリジナルの人型モデルを持ち込めるというのが利点です。
ゆかりさんとXXでもVRマルチプレイに対応するにあたってモデルの最適化が一番時間のかかる作業だったため、VRMに対応しておけばその最適化作業を削減できると考えています。
以下公式のドキュメントで最低限見るべきページをまとめておきます。
「VRM」って何?どんなことができる? - dwango on GitHub
VRMファイルを作ってみたい - dwango on GitHub
VRMモデルを実行時にインポートする - dwango on GitHub

問題

  1. モデルをロードしたときにPhotonから切断させる(Noプレハブ)
    モデルデータを圧縮せずに共有した結果タイムアウトが起きていた?モデルデータを圧縮すればできるかも?
    10 行でズバリ!! 圧縮と展開 (C#) 言語: C#
    C# MemoryStreamを使用した解凍(DeflateStream) - ネットワークとプログラム
    出来ませんでした。圧縮しても切断される。
    クラス変数に保存してそれを読むようにしたら切断されないので、Photonを介してデータのやり取りをしているのが問題。
    VRMモデルは私が勝手に変換した結月ゆかり公式モデルが7MB、アリシア・ソリッドちゃんが10MBとそこそこファイルサイズが大きく、これをPhoton経由で共有するのは現実的ではないと思います。事実切断されていますし。
    どうしてもやりたいなら、サーバを別途用意してそこにモデルデータをアップロードしておき、要求があったら各クライアントがサーバからDLする方法がいいと思います。
    サーバを設置するのが面倒なので事前にUnityにVRMを読み込んでプレハブを生成し、それを弄ることにします。

  2. ボイスチャットが音割れする
    OVRLipSyncとPhotonVoiceの相性がメチャクチャ悪くて、どう頑張ってもボイスチャットが音割れ?ブツブツ?になってしまいました。
    リップシンクに別アセットを使用することで解決しました。

VRマルチプレイとVRMモデルに関してはスペースが無いので次の記事に詳細を書きます。

VRIKの再カスタマイズ

Vive Tracker 2018 が手に入るまで延期。 4月24日に届きました。


Trackerで足腰の動きを取るために設定していきます。
まずココを参考に、Trackerを認識させるためにCameraRigにある SteamVR_ControllerManager に追加オブジェクトを認識させます。
スクリーンショット (184).png
追加オブジェクトのモデルを変更しようと思って SteamVR_RenderModel の ModelOverride を設定しましたが、なぜかモデルが表示されなくなってしまったので、デフォルトの状態で試したらTrackerのモデルが表示されるようになりました。
スクリーンショット (185).png

Trackerを認識させるようにしたら、VRIKの設定を行っていきます。
腕のコントローラに対してターゲットを設定したように、足腰のTrackerに対してもターゲットを設定してあげればOKです。
スクリーンショット (183).png
私のリアル部屋の環境だと腰のトラッカーが不安定なため、腰のトラッカーだけ設定から削除しました。
その場合、Locomotion のウェイトを1にして腰を自動で動くようにしてあげないと、腰が変な方向へ曲がってしまうので注意です。

これ以降の更新は別記事で行います

記事が長くなってきたので、これ以降の更新は別記事に書きたいと思います。
新しい記事はコチラです。