Unity

VRで「結月ゆかり」になって生放送する

はじめに

どうも@toRisouPと申します。ドワンゴにてニコニコ生放送(新配信)の開発を行っています。

皆さんはニコニコ生放送、利用されてますでしょうか?
自分はたまに生放送を行っています!
自分が配信する放送のうち8割はゲーム配信なのですが、残り2割は思いついた技術ネタを作ってみて生放送で実演するという内容になっています。
今回は自分の過去のネタの中から1つピックアップして紹介したいと思います。

ゆかりごっこ

「ゆかりごっこ」は過去に自分が行った生放送の中で一番好きなネタです。
成人男性の9割は美少女になりたいという願望があるはずです。
その願いを叶えるために、VRで結月ゆかりさんになれるアプリを作り、それを実際に使って行った生放送が「ゆかりごっこ」です。

yuka5.gif

具体的にどのような生放送を行ったかは以下のアーカイブをご覧頂けるとよいかと思います。

1.png
2.png
3.png

使っている機材とかツールとか構成とか

このゆかりごっこを実現するために利用している機材やアプリケーションは以下のものになります。
(作成が2017年2月なので、当時のバージョンで記載してあります)

機材

  • HTC Vive
  • デスクトップPC(i7 2700K、メモリ16GB、GTX970)
  • スタンドマイク
  • ワイヤレスヘッドホン

開発ツール、ライブラリ

その他連携ソフト

使用モデル

構成図

image.png

技術的な話

モデルデータの取り込み

まずはMMDのモデルデータをUnityに取り込み詳細設定する方法を説明します。

Unityに取り込むのは簡単で、MMD4Mecanimで公式MMDモデルを変換するだけで終わります。

(注意:MMDモデルは歴史的経緯によりUnity等のゲームエンジンでの利用が禁止されている場合があります。必ずMMDモデルのライセンスを確認し、ライセンスが不明であればモデル作者様に直接連絡して承諾を得たモデルのみを利用するようにして下さい。)

IKの設定

続いてIKを設定していきます。
IKとはInverse Kinematicsの略で、簡単に言えばモデルの顔や手足の姿勢を良い感じに制御するやつです。

IKの実装手法はいろいろありますが、今回はFinalIKを使って済ますことにしました。
image.png
(IKを良い感じやってくれる便利アセットです。VRにも対応。)

設定方法は単純な割に作業量が多いので、他の方が紹介された記事を紹介して、詳しい説明は割愛させていただきます。
(参考例: Unity+Vive+MMD+VRIKで、キズナアイちゃんになりきりVR

カメラの設定

次に「カメラ」をVR空間に配置し、そのカメラから撮影された映像を生放送で配信できるようにします。
この時、「VRゴーグルに投影する映像」と「配信する映像」を別々にすることで「テレビ番組」感が出るようにします。

image.png

カメラの設定ですが、今回はUnityCamというプラグインを利用しました。
UnityCam設定したRenderTextureへの描画をWebカメラからの映像としてアプリケーション外に出力できるようになるプラグインです。
つまり、アプリ内にWebカメラを持ち込んで配信できるようになるような便利プラグインです。

今回はこちらをVR上のカメラに設定することで、VRゴーグルで見ている映像とは別に撮影して配信するようにしました。
image.png
(VR空間上に配置した「カメラ」から撮影された映像を生放送にて配信する)

image.png

カメラの切り替え

カメラが1つだけだと単調ですぐ飽きてしまうので、複数のカメラを任意に切り替えられるようにしました。

yuka1.gif
(オブジェクトに触れると撮影しているカメラが切り替わる)

カメラ切り替えのボタン部分

ボタン部分はVRTKのVRTK_Buttonコンポーネントを配置しただけです。
このコンポーネントを使うことでオブジェクト同士の干渉が行えるようになるので、オブジェクトが奥に押し込まれたらイベントが飛ぶようにしています。

image.png
(ボタンオブジェクト。床から少し浮くように配置されており、押し込むと反応する。)

切り替えスクリプト

続いてボタンが押されたらカメラが切り替わるようにします。こちらは簡単なスクリプトを書いて対応しました。
UniRxのReactivePropetyを使い、現在選択中のカメラのIdが上書きされたらそれに追従してカメラの表示を切り替えるようにしています。

現在使用中のカメラを保持するマネージャ
/// <summary>
/// カメラ管理
/// </summary>
public class TvCameraManager : MonoBehaviour
{
    //現在使用中のカメラID
    public TvCameraIdReactiveProperty CurrentCameraId;

    //現在選択されているカメラオブジェクト
    private ReactiveProperty<TvCamera> _currentCamera = new ReactiveProperty<TvCamera>();

    public IReadOnlyReactiveProperty<TvCamera> CurrentCamera { get { return _currentCamera; } }

    //利用可能なカメラリスト
    private Dictionary<TvCameraId, TvCamera> _cameraList = new Dictionary<TvCameraId, TvCamera>();

    void Start()
    {
        CurrentCameraId.Subscribe(id =>
        {
            if (_cameraList.ContainsKey(id))
            {
                _currentCamera.Value = _cameraList[id];
            }
        });

        CurrentCameraId.Value = TvCameraId.Cam1;
    }

    public void RegisterCamera(TvCameraId id, TvCamera cam)
    {
        _cameraList.Add(id, cam);
        if (id == CurrentCameraId.Value)
        {
            _currentCamera.SetValueAndForceNotify(cam);
        }
    }
}
カメラの有効・無効を制御するコンポーネント
/// <summary>
/// カメラ制御コンポーネント
/// </summary>
public class TvCamera : MonoBehaviour
{
    [SerializeField]
    private TvCameraId _id; //カメラのID

    private Camera camera; //Unityのカメラコンポーネント
    private UnityCam unityCam; //UnityCamコンポーネント

    public Camera MainCamera { get { return camera; } }

    [Inject]
    private TvCameraManager cameraManager;

    void Start()
    {
        camera = GetComponent<Camera>();
        unityCam = GetComponent<UnityCam>();

        //初期化時にManagerにカメラ登録
        cameraManager.RegisterCamera(_id, this);

        //現在利用中のカメラIDが変化したら追従する
        cameraManager.CurrentCameraId.Subscribe(current =>
        {
            if (current == _id)
            {
                CameraEnable();
            }
            else
            {
                CameraDisable();
            }
        });
    }

    void CameraEnable()
    {
        unityCam.enabled = true;
        camera.enabled = true;
    }

    void CameraDisable()
    {
        unityCam.enabled = false;
        camera.enabled = false;
    }
}
ボタンオブジェクトに貼り付けるコンポーネント
/// <summary>
/// ボタンオブジェクトに設定されるコンポーネント
/// </summary>
public class CameraSwitchButton : MonoBehaviour
{
    [SerializeField]
    private TvCameraId cameraId; //担当するカメラID

    [Inject]
    private TvCameraManager cameraManager;

    private VRTK_Button button;

    void Start()
    {
        button = GetComponent<VRTK_Button>();

        button.Pushed += (sender, args) =>
        {
            //ボタンが押されたら自身のIDのカメラに変更する
            cameraManager.CurrentCameraId.Value = cameraId;
        };
    }
}

持ち運び可能カメラも作る

せっかくゆかりさんになったのなら自撮りがしたいので、「持ち運び可能なカメラ」も作成しました。

image.png
(スマートフォンモデルにカメラを貼り付けて持てるようにしただけ)

yuka3.gif
(俺かわいい)

音声認識と字幕表示

声もゆかりさんになるようにしてみます。

基礎技術は一昨年にアドベントカレンダーで書いた内容と殆ど同じです。
今回は音声認識&Voiceroid連携が簡単にできるゆかりねっとを使用させて頂きました。
これ1つで簡単に音声認識→ゆかりさんで読み上げができるようになるのでおすすめです。

字幕表示

音声で読み上げだけだと、たまに何を言っているのかわからないことがあるので認識した結果が字幕も出るようにします。

ゆかりねっと→Unity

まずは認識した結果をUnityに転送する必要があるので、そのゆかりねっと用のプラグインを作成します。
突貫で作ったので割とガバガバです。

ゆかりねっと側

プラグインのエントリポイント
using System.Threading;
using Yukarinette;

namespace YukarinetteToUnity
{
    public class YukarinetteToUnityPlugin : IYukarinetteInterface
    {
        public override string Name { get; } = "Unity転送プラグイン";

        private TCPSender sender;
        private int port = 17306;
        private CancellationTokenSource ctoken;

        public override void Speech(string text)
        {
            base.Speech(text);

            if (sender == null || !sender.IsRunning) return;

            var m = new Message(text);
            var j = m.ToJson();
            sender.SendToAll(j);
        }

        public override void SpeechRecognitionStop()
        {
            base.SpeechRecognitionStop();
            ctoken?.Cancel();
            sender.Stop();
        }

        public override void SpeechRecognitionStart()
        {
            base.SpeechRecognitionStart();
            if (sender == null) sender = new TCPSender();
            sender.Stop();
            ctoken = sender.Start(port);
        }
    }
}
転送メッセージ(json)
using System.IO;
using System.Runtime.Serialization;
using System.Runtime.Serialization.Json;

namespace YukarinetteToUnity
{
    [DataContract]
    public class Message
    {
        [DataMember]
        public string Text;


        DataContractJsonSerializer jsonSerializer;

        public Message(string text)
        {
            jsonSerializer = new DataContractJsonSerializer(typeof(Message));
            this.Text = text;
        }

        public string ToJson()
        {
            var result = "";
            using (var stream = new MemoryStream())
            {
                jsonSerializer.WriteObject(stream, this);

                stream.Position = 0;
                using (var reader = new StreamReader(stream))
                {
                    result = reader.ReadToEnd();
                }
            }
            return result;
        }
    }
}
TCPソケット通信
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace YukarinetteToUnity
{
    public class TCPSender
    {
        TcpListener listener;
        List<TcpClient> tcpClients;
        Encoding encoding;
        public bool IsRunning { get; private set; }
        TaskFactory taskFactory = new TaskFactory();
        private Task currentTask;
        private CancellationTokenSource currentCancellationTokenSource;
        public int Port { get; private set; }

        public TCPSender()
        {
            tcpClients = new List<TcpClient>();
            encoding = System.Text.Encoding.UTF8;
        }

        public CancellationTokenSource Start(int port)
        {
            Disconnect();
            Port = port;
            this.listener = new TcpListener(System.Net.IPAddress.Parse("127.0.0.1"), port);
            this.listener.Start();
            var tokenSource = new CancellationTokenSource();
            currentCancellationTokenSource = tokenSource;
            currentTask = taskFactory.StartNew(async () =>
            {
                while (true)
                {
                    tokenSource.Token.ThrowIfCancellationRequested();
                    TcpClient client = await listener.AcceptTcpClientAsync();
                    client.NoDelay = true;
                    tcpClients.Add(client);
                    Debug.Print("接続{0}", ((IPEndPoint)(client.Client.RemoteEndPoint)).Address);
                }
            }, tokenSource.Token);
            IsRunning = true;
            return currentCancellationTokenSource;
        }

        public void Stop()
        {
            Disconnect();
        }

        private void Disconnect()
        {
            IsRunning = false;
            listener?.Stop();
            currentCancellationTokenSource?.Cancel();
            currentTask?.Wait();
        }

        /// <summary>
        /// 全てのクライアントにMessageをブロードキャストする
        /// </summary>
        /// <param name="client_data"></param>
        /// <param name="message"></param>
        public void SendToAll(string message)
        {
            //接続が切れているクライアントは除去する
            var closedClients = tcpClients.Where(x => !x.Connected).ToList();
            closedClients.ForEach(x => tcpClients.Remove(x));

            foreach (var client in tcpClients)
            {
                //接続が切れていないか再確認
                if (!client.Connected) { continue; }
                var ns = client.GetStream();
                byte[] message_byte = encoding.GetBytes(message);
                try
                {
                    do
                    {
                        ns.WriteAsync(message_byte, 0, message_byte.Length);
                    } while (ns.DataAvailable);
                }
                catch (Exception e)
                {
                    Debug.Print(e.Message);
                    if (!client.Connected)
                    {
                        client.Close();
                    }
                }
            }
        }
    }
}

Unity側

通信Client
public class YukarrinetteClient
{
    TcpClient tcpClient;
    private Subject<MessageData> _messageSubject;
    byte[] buffer;

    public IObservable<MessageData> OnRecievedMessage { get { return _messageSubject; } }

    public bool IsConnected
    {
        get { return this.tcpClient != null && this.tcpClient.Connected; }
    }

    public YukarrinetteClient()
    {
        _messageSubject = new Subject<MessageData>();
        buffer = new byte[2048];
    }

    public void Connect(string host, int port)
    {
        if (IsConnected)
        {
            Disconnect();
        }

        tcpClient = new TcpClient(host, port);
        tcpClient.GetStream().BeginRead(buffer, 0, buffer.Length, CallBackBeginReceive, null);
    }

    private void CallBackBeginReceive(IAsyncResult ar)
    {
        try
        {
            var bytes = this.tcpClient.GetStream().EndRead(ar);

            if (bytes == 0)
            {
                //接続断
                Disconnect();
                return;
            }

            var recievedMessage = Encoding.UTF8.GetString(buffer, 0, bytes);
            var m = MessageData.FromJson(recievedMessage);
            _messageSubject.OnNext(m);
            tcpClient.GetStream().BeginRead(buffer, 0, buffer.Length, CallBackBeginReceive, null);
        }
        catch (Exception e)
        {
            Disconnect();
        }
    }

    public void Disconnect()
    {
        if (tcpClient != null && tcpClient.Connected)
        {
            tcpClient.GetStream().Close();
            tcpClient.Close();
            tcpClient = null;
        }
    }
}
Unityコンポーネントとして通信Clientを触る部分
public class YukarinetteRecieverComponent : MonoBehaviour
{
    public string Host = "127.0.0.1";
    public int Port = 17306;
    public bool ConnectOnAwake;

    private YukarrinetteClient client;

    private IObservable<MessageData> stream;

    public IObservable<MessageData> OnRecievedMessageAsObservable
    {
        get { return stream; }
    }

    void Awake()
    {
        client = new YukarrinetteClient();
        if (ConnectOnAwake)
        {
            Connect();
        }
        stream = client.OnRecievedMessage.ObserveOnMainThread().Publish().RefCount();
    }

    void OnDestroy()
    {
        client.Disconnect();
    }

    public void Connect()
    {
        client.Connect(Host, Port);
    }

    public void Disconnect()
    {
        client.Disconnect();
    }
}
メッセージオブジェクト
public struct MessageData
{
    public string Text;

    public static MessageData FromJson(string json)
    {
        return JsonUtility.FromJson<MessageData>(json);
    }
}

物凄く単純なJsonを生成してTcpSocketで通信、Unity側で受信しているだけです。
特筆するとすれば、UniRxを利用してメインスレッドへの切り替えを1行でやっているくらいです。

    private YukarrinetteClient client;

    private IObservable<MessageData> stream;

    public IObservable<MessageData> OnRecievedMessageAsObservable
    {
        get { return stream; }
    }

    void Awake()
    {
        client = new YukarrinetteClient();
        if (ConnectOnAwake)
        {
            Connect();
        }
        //ここ
        stream = client.OnRecievedMessage.ObserveOnMainThread().Publish().RefCount();
    }

字幕の描画

まずuGUI Canvasを1つ用意し、Render ModeをScreen Space - Cameraに設定します。こうすることで指定したカメラにUIが張り付くようになります。なのでこのCanvasに受け取った音声認識の結果をTextとして描画してあげることで、リアルタイムの字幕を描画することができるようになります。

image.png
(現在使用中のカメラをRender Cameraに逐一設定してあげることで、カメラが切り替わってもUIが追従するようになる。)

image.png
(カメラに張り付いたUI)

今回はカメラとUIの距離を11cmに設定したのですが、まだちょっと距離がありすぎて字幕がオブジェクトにめり込むことがありました。
この辺りは調整していきたいです。

yuka4.gif
(UIをカメラの前に描画している都合上、その間にオブジェクトがあると字幕が表示されない)

音声認識結果の運営コメント投稿

音声認識した結果を当時にニコ生に投稿できるようにしました。
これもゆかりねっとのプラグインを書いて対応しています。

ゆかりねっとからニコ生の運営コメントを投稿するプラグイン作った【新配信専用版】

リップシンク

リップシンクはOculus社が過去に提供していたOVRLipSyncを用いて行いました。

詳しい解説は凹さんがブログにて解説しているのでこちらを参照下さい。
Unity でリップシンクができる OVRLipSync を試してみた

OVRLipSyncContextMorphTargetを使うことで、音声に合わせてモデルのモーフを変化させることができるようになります。
ここの調整が結構面倒くさいのですが、今回は次のようなモーフ設定にしました。
image.png
(どの要素が何に対応しているかは説明が面倒くさいので略。)

あとは「VOICEROID+ 結月ゆかりEX」の設定で音声の出力先デバイスが指定できるのでそこをステレオミキサに接続し、
Unity側もそこに吸い付かせることでゆかりさんの発話に合わせてリップシンクができるようになります。

表情とポーズの変更

せっかくの可愛いモデルなので、表情やポーズも変えられるようにしました。

表情
hyoujou.gif

視線
mesen.gif

手の形
yuka5.gif

それぞれ解説します。

表情と視線

表情と視線はViveコントローラのトラックパッドを触ることで変更することができるようになっています。
cont.png
(この親指で触る部分がタッチセンサになっており、触っている座標が取得できる)

ゆかりごっこでは左手で表情を、右手で視線を変更できるように設定しました。

pad.png

スクリプト

表情を変更するコンポーネントとしてFaciemControlleを使わせて頂いています。

表情パターン
public enum FacePattern
{
    Default,
    Ikari,
    Jito,
    Metoji,
    Smile
}
表情と視線を変更するスクリプト
public class FaceController : MonoBehaviour
{
    [SerializeField]
    private FaciemController faciemController;

    [SerializeField]
    private MMD4MecanimBone[] eyes;

    private ReactiveProperty<FacePattern> _currentFace = new ReactiveProperty<FacePattern>(FacePattern.Default);

    public IReadOnlyReactiveProperty<FacePattern> CurrentFace { get { return _currentFace; } }

    void Start()
    {
        faciemController = GetComponent<FaciemController>();

        _currentFace.Subscribe(x =>
        {
            faciemController.SetFace(x.ToString());
        });

        this.LeftTouchPosition()
            .Subscribe(x =>
            {
                var length = x.magnitude;

                if (length < 0.5f)
                {
                    _currentFace.Value = FacePattern.Default;
                    return;
                }

                if (Vector2.Angle(Vector2.up, x) < 45.0f)
                {
                    _currentFace.Value = FacePattern.Smile;
                }
                else if (Vector2.Angle(Vector2.right, x) < 45.0f)
                {
                    _currentFace.Value = FacePattern.Ikari;
                }
                else if (Vector2.Angle(Vector2.left, x) < 45.0f)
                {
                    _currentFace.Value = FacePattern.Jito;
                }
                else if (Vector2.Angle(Vector2.down, x) < 45.0f)
                {
                    _currentFace.Value = FacePattern.Metoji;
                }

            });

        Quaternion eyeTarget = Quaternion.identity;

        this.UpdateAsObservable()
            .Subscribe(_ =>
            {
                foreach (var e in eyes)
                {
                    e.userRotation = Quaternion.Lerp(
                        e.userRotation,
                        eyeTarget,
                        Time.deltaTime * 10.0f
                        );
                }
            });

        this.RightTouchPosition()
            .Subscribe(i =>
            {

                eyeTarget = Quaternion.Euler(-i.y * 5.0f, i.x * 10.0f, 0);
            });

    }
}

SteamVRのInputが驚くほど使いにくかったので、入力イベントをObservableに変換してUniRxで扱えるようにしています。
Observable化については別記事にしていますのでそちらをご覧ください。

SteamVR PluginのInputEventをUniRxで扱えるようにするAsset作った

指の形

指の形は次の6ポーズが取れるようになっています。

image.png

それぞれViveコントローラのトリガーとアプリケーションボタンを押した組み合わせで変わるにようなっています。

  • 何も押していない → パー
  • トリガーを引く → グー
  • トリガーを引いた状態でアプリケーションキーを1回押す → 「1」の指
  • トリガーを引いた状態でアプリケーションキーを2回押す → 「2」の指(チョキ)
  • トリガーを引いた状態でアプリケーションキーを3回押す → 「3」の指
  • トリガーを引いた状態でアプリケーションキーを4回押す → 「4」の指

なお、みゅみゅさんが作成されたMMD4FingerControllerを使って指先の制御をしています。

指先を制御するスクリプト(みゅみゅさん作)
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
using System;


/*
 * MMD4mecanimの指の制御を行う
 *  modelがアニメーションであることが前提
 * 
 */

public class MMD4FingerController : MonoBehaviour
{
    // 指の場所の定数
    public enum FingerType
    {
        LeftIndex,
        LeftMiddle,
        LeftRing,
        LeftLittle,
        RightIndex,
        RightMiddle,
        RightRing,
        RightLittle,
        LeftThumb,
        RightThumb,
        LeftAll,
        RightAll,
        All
    }


    private static readonly Dictionary<FingerType, Dictionary<HumanBodyBones, Vector3>> FingerBoneMap = new Dictionary<FingerType, Dictionary<HumanBodyBones, Vector3>>
    {
        {FingerType.LeftIndex, new Dictionary<HumanBodyBones,Vector3>
            {
                { HumanBodyBones.LeftIndexProximal,         new Vector3(0f,0f,80f) },
                { HumanBodyBones.LeftIndexIntermediate,     new Vector3(0f,0f,70f) },
                { HumanBodyBones.LeftIndexDistal,           new Vector3(0f,0f,90f) },
            }
        },
        {FingerType.LeftMiddle, new Dictionary<HumanBodyBones,Vector3>
            {
                { HumanBodyBones.LeftMiddleProximal,        new Vector3(0f,0f,80f) },
                { HumanBodyBones.LeftMiddleIntermediate,    new Vector3(0f,0f,70f) },
                { HumanBodyBones.LeftMiddleDistal,          new Vector3(0f,0f,90f) },
            }
        },
        {FingerType.LeftRing, new Dictionary<HumanBodyBones,Vector3>
            {
                { HumanBodyBones.LeftRingProximal,          new Vector3(0f,0f,80f) },
                { HumanBodyBones.LeftRingIntermediate,      new Vector3(0f,0f,70f) },
                { HumanBodyBones.LeftRingDistal,            new Vector3(0f,0f,90f) },
            }
        },
        {FingerType.LeftLittle, new Dictionary<HumanBodyBones, Vector3>
            {
                { HumanBodyBones.LeftLittleProximal,        new Vector3(0f,0f,80f) },
                { HumanBodyBones.LeftLittleIntermediate,    new Vector3(0f,0f,70f) },
                { HumanBodyBones.LeftLittleDistal,          new Vector3(0f,0f,90f) },
            }
        },

        {FingerType.RightIndex, new Dictionary<HumanBodyBones, Vector3>
            {
                { HumanBodyBones.RightIndexProximal,        new Vector3(0f,0f,-80f) },
                { HumanBodyBones.RightIndexIntermediate,    new Vector3(0f,0f,-70f) },
                { HumanBodyBones.RightIndexDistal,          new Vector3(0f,0f,-90f) },
            }
        },
        {FingerType.RightMiddle, new Dictionary<HumanBodyBones, Vector3>
            {
                { HumanBodyBones.RightMiddleProximal,       new Vector3(0f,0f,-80f) },
                { HumanBodyBones.RightMiddleIntermediate,   new Vector3(0f,0f,-70f) },
                { HumanBodyBones.RightMiddleDistal,         new Vector3(0f,0f,-90f) },
            }
        },
        {FingerType.RightRing, new Dictionary<HumanBodyBones, Vector3>
            {
                { HumanBodyBones.RightRingProximal,         new Vector3(0f,0f,-80f) },
                { HumanBodyBones.RightRingIntermediate,     new Vector3(0f,0f,-70f) },
                { HumanBodyBones.RightRingDistal,           new Vector3(0f,0f,-90f) },
            }
        },
        {FingerType.RightLittle, new Dictionary<HumanBodyBones, Vector3>
            {
                { HumanBodyBones.RightLittleProximal,       new Vector3(0f,0f,-80f) },
                { HumanBodyBones.RightLittleIntermediate,   new Vector3(0f,0f,-70f) },
                { HumanBodyBones.RightLittleDistal,         new Vector3(0f,0f,-90f) },
            }
        },

        {FingerType.LeftThumb, new Dictionary<HumanBodyBones, Vector3>
            {
                { HumanBodyBones.LeftThumbProximal,         new Vector3(0f,0f,0f) },
                { HumanBodyBones.LeftThumbIntermediate,     new Vector3(43f,28f,55f) },
                { HumanBodyBones.LeftThumbDistal,           new Vector3(34f,-56f,7f) },
            }
        },

        {FingerType.RightThumb, new Dictionary<HumanBodyBones, Vector3>
            {
                { HumanBodyBones.RightThumbProximal,        new Vector3(0f,0f,0f) },
                { HumanBodyBones.RightThumbIntermediate,    new Vector3(43f,-28f,-55f) },
                { HumanBodyBones.RightThumbDistal,          new Vector3(34f,56f,-7f) },
            }
        },
    };


    // MMD4Mecanimbone格納場所
    private Dictionary<HumanBodyBones, MMD4MecanimBone> MMD4MecanimBones = new Dictionary<HumanBodyBones, MMD4MecanimBone>();

    // 現在の値
    private List<float> FingerNowVal = new List<float>();


    public void Awake()
    {
        // ボーンマップ生成
        MapBones();

        // 現在の指の値をすべて0にする
        for (int i = 0; i < Enum.GetNames(typeof(FingerType)).Length; i++)
        {
            FingerNowVal.Add(0f);
        }
    }


    /// <summary>
    /// 指を動かす
    /// </summary>
    /// <param name="ft"></param>
    /// <param name="val"></param>
    public void FingerRotation(FingerType ft, float val)
    {
        switch (ft)
        {
            case FingerType.All:
                FingerLeftAllRotation(val);
                FingerRightAllRotation(val);
                break;
            case FingerType.LeftAll:
                FingerLeftAllRotation(val);
                break;
            case FingerType.RightAll:
                FingerRightAllRotation(val);
                break;
            default:
                FingerSeparateRotation(ft, val);
                break;
        }
    }

    /// <summary>
    /// 左手全部
    /// </summary>
    /// <param name="val"></param>
    void FingerLeftAllRotation(float val)
    {
        FingerSeparateRotation(FingerType.LeftIndex, val);
        FingerSeparateRotation(FingerType.LeftLittle, val);
        FingerSeparateRotation(FingerType.LeftMiddle, val);
        FingerSeparateRotation(FingerType.LeftRing, val);
        FingerSeparateRotation(FingerType.LeftThumb, val);
    }

    /// <summary>
    /// 右手全部
    /// </summary>
    /// <param name="val"></param>
    void FingerRightAllRotation(float val)
    {
        FingerSeparateRotation(FingerType.RightIndex, val);
        FingerSeparateRotation(FingerType.RightLittle, val);
        FingerSeparateRotation(FingerType.RightMiddle, val);
        FingerSeparateRotation(FingerType.RightRing, val);
        FingerSeparateRotation(FingerType.RightThumb, val);
    }



    /// <summary>
    /// 指定した指を動かす
    /// </summary>
    /// <param name="ft"></param>
    /// <param name="val">0-1</param>
    void FingerSeparateRotation(FingerType ft, float val)
    {
        val = Mathf.Clamp01(val);
        var _finger = FingerBoneMap[ft];

        foreach (var _obj in _finger.Keys)
        {
            MMD4MecanimBones[_obj].userRotation = Quaternion.Euler(Vector3.Lerp(Vector3.zero, _finger[_obj], val));
        }

        // 値を記憶
        FingerNowVal[(int)ft] = val;

    }

    /// <summary>
    /// ボーンマップを生成
    /// </summary>
    void MapBones()
    {
        Animator animatorComponent = GetComponent<Animator>();

        foreach (var _finger in FingerBoneMap.Values)
        {
            foreach (var _obj in _finger.Keys)
            {
                MMD4MecanimBones.Add(_obj, animatorComponent.GetBoneTransform(_obj).GetComponent<MMD4MecanimBone>());
            }
        }
    }

    /// <summary>
    /// 指の名前から回転の最大値を求める
    /// </summary>
    /// <param name="name"></param>
    /// <returns></returns>
    Vector3 GetFingerMaxRotate(HumanBodyBones name)
    {
        foreach (var _finger in FingerBoneMap.Values)
        {
            foreach (var _obj in _finger.Keys)
            {
                if (_obj == name)
                {
                    return _finger[_obj];
                }
            }
        }
        return Vector3.zero;
    }

}
手の形を制御するスクリプト
public class FingerController : MonoBehaviour
{
    private MMD4FingerController finger;

    void Start()
    {
        finger = GetComponent<MMD4FingerController>();

        var rd = this.OnRightApplicationMenuPressDownAsObservable();
        var rp = this.RightApplicationMenuPress();
        rd.Buffer(rd.Throttle(TimeSpan.FromMilliseconds(200)))
            .Select(x => x.Count)
            .Subscribe(x =>
            {
                SetRightHand(x);
                rp
                .FirstOrDefault(s => !s)
                .Where(_ => !this.RightTriggerPress().Value)
                .Subscribe(_ => SetRightFree()).AddTo(this);
            });

        this.RightTriggerPress()
            .Subscribe(x =>
            {
                if (x)
                {
                    SetRightGrab();
                }
                else
                {
                    SetRightFree();
                }
            });

        this.LeftTriggerPress()
            .Subscribe(x =>
            {
                if (x)
                {
                    SetLeftGrab();
                }
                else
                {
                    SetLeftFree();
                }
            });

        var ld = this.OnLeftApplicationMenuPressDownAsObservable();
        var lp = this.LeftApplicationMenuPress();

        ld.Buffer(ld.Throttle(TimeSpan.FromMilliseconds(300)))
            .Select(x => x.Count)
            .Subscribe(x =>
            {
                SetLeftHand(x);
                lp.FirstOrDefault(s => !s)
                .Where(_ => !this.LeftTriggerPress().Value)
                .Subscribe(_ => SetLeftFree()).AddTo(this);
            });
    }

    #region Right
    private void SetRightHand(int num)
    {
        switch (num)
        {
            case 0:
                SetRightGrab();
                break;
            case 1:
                SetRightOne();
                break;
            case 2:
                SetRightTwo();
                break;
            case 3:
                SetRightThree();
                break;
            case 4:
                SetRightFow();
                break;
            case 5:
            default:
                SetRightFree();
                break;
        }
    }

    private void SetRightGrab()
    {
        finger.FingerRotation(MMD4FingerController.FingerType.RightAll, 1);
    }

    private void SetRightFree()
    {
        finger.FingerRotation(MMD4FingerController.FingerType.RightAll, 0);
    }

    private void SetRightOne()
    {
        finger.FingerRotation(MMD4FingerController.FingerType.RightAll, 1);
        finger.FingerRotation(MMD4FingerController.FingerType.RightIndex, 0);
    }

    private void SetRightTwo()
    {
        finger.FingerRotation(MMD4FingerController.FingerType.RightAll, 1);
        finger.FingerRotation(MMD4FingerController.FingerType.RightIndex, 0);
        finger.FingerRotation(MMD4FingerController.FingerType.RightMiddle, 0);
    }

    private void SetRightThree()
    {
        finger.FingerRotation(MMD4FingerController.FingerType.RightAll, 1);
        finger.FingerRotation(MMD4FingerController.FingerType.RightIndex, 0);
        finger.FingerRotation(MMD4FingerController.FingerType.RightMiddle, 0);
        finger.FingerRotation(MMD4FingerController.FingerType.RightRing, 0);
    }


    private void SetRightFow()
    {
        finger.FingerRotation(MMD4FingerController.FingerType.RightAll, 1);
        finger.FingerRotation(MMD4FingerController.FingerType.RightIndex, 0);
        finger.FingerRotation(MMD4FingerController.FingerType.RightMiddle, 0);
        finger.FingerRotation(MMD4FingerController.FingerType.RightRing, 0);
        finger.FingerRotation(MMD4FingerController.FingerType.RightLittle, 0);
    }
    #endregion

    #region Left
    //左手省略
    #endregion
}

こういう「ボタンをN回押した」みたいな処理はRxの得意分野ですね。

生放送連動

ニコニコ生放送の最大の特徴は「コメント」を使ってリスナーとインタラクションが取りやすいところだと思っています。
そのためコメントを使っていろいろ遊べるようにしてみました。

  • コメントでカメラ切り替え
  • コメントした人のアイコンが振ってくる

それぞれ解説します。

Unityでコメントの取り込み

コメントでインタラクションを取るために、Unityにコメント情報を引っ張ってくる必要があります。
ただ自分でニコニコのログインAPIを叩いてログインして、ニコ生のAPIを叩いてコメントサーバ一覧を取得し、コメントサーバにXmlSocketをつなぐ、という処理は結構めんどうくさいです。
なので今回はコメントビューアからコメント情報を抜き取ってUnityに転送するようにしました。

このあたりの実装も過去に記事にしてまとめているので、そちらを参照して下さい。

Unityでニコニコ生放送のコメントを取得する

コメントでカメラ切り替え

「1かめ」「2かめ」「3かめ」といったコメントに反応してカメラを切り替えられるようにしました。
該当のコメントを受信したら、少し前に説明したTvCameraManagerのCameraIdを書き換えています。

コメントでカメラ切り替え
public class CommentCameraChanger : MonoBehaviour
{
    [Inject]
    private TvCameraManager camaeraManager;

    [Inject]
    private NicoliveCommentRecieveComponent commentReciever;

    void Start()
    {
        commentReciever.MessageRecievedEvent += (sender, args) =>
        {
            Set(args.Comment.Message);
        };
    }

    void Set(string str)
    {

        if (str.Contains("1かめ") || str.Contains("1かめ"))
        {
            camaeraManager.CurrentCameraId.Value = TvCameraId.Cam1;
        }
        if (str.Contains("2かめ") || str.Contains("2かめ"))
        {
            camaeraManager.CurrentCameraId.Value = TvCameraId.Cam2;
        }
        if (str.Contains("3かめ") || str.Contains("3かめ"))
        {
            camaeraManager.CurrentCameraId.Value = TvCameraId.Cam3;
        }
    }
}

コメントした人のアイコンを表示する

コメントした人のニコニコアカウントのアイコンを取得し、Cubeのテクスチャとして描画することにしました。
このCubeには判定があり、掴んだり投げることができるようになっています。

image.png
(散らばっている白っぽい箱がそれ)

リスナーが184をつけていなければニコニコのユーザIDがわかるため、そのIDに紐付いたアイコンイメージをAPIを使って取得します。

ユーザIDからアイコンを取得する
public class CommentItemSpawner : MonoBehaviour
{
    [Inject]
    private NicoliveCommentRecieveComponent commentReciever;

    private Dictionary<string, Texture2D> texture2Cache = new Dictionary<string, Texture2D>();

    [SerializeField]
    private GameObject IconItem;

    void Start()
    {
        texture2Cache.Clear();

        Observable
            .FromEventPattern<NicoliveCommentRecieveComponent.MessageRecievedHandler, CommentRecievedEventArgs>(
                h => h.Invoke,
                h => commentReciever.MessageRecievedEvent += h,
                h => commentReciever.MessageRecievedEvent -= h
            )
            .SelectMany(x =>
            {
                return Observable.FromCoroutine<Texture2D>(o => UserIconCoroutine(o, x.EventArgs.Comment.UserId));
            })
            .Subscribe(t =>
            {
                var rv = new Vector3(
                        Random.Range(-0.3f, 0.3f),
                        0,
                        Random.Range(-0.3f, 0.3f)
                    );
                var go = Instantiate(
                    IconItem,
                    transform.position + rv,
                    Quaternion.AngleAxis(Random.Range(0, 90), Vector3.up) * transform.rotation);
                var sc = go.GetComponent<NiconicoIconObject>();
                sc.SetTexture(t);
            }).AddTo(this);
    }

    IEnumerator UserIconCoroutine(IObserver<Texture2D> observer, string userIdStr)
    {

        if (texture2Cache.ContainsKey(userIdStr))
        {
            //キャッシュヒットしたら終了
            if (texture2Cache[userIdStr] != null) observer.OnNext(texture2Cache[userIdStr]);
            observer.OnCompleted();
            yield break;
        }

        var userId = 0;
        if (!int.TryParse(userIdStr, out userId))
        {
            //不正なユーザID
            texture2Cache[userIdStr] = null;
            observer.OnCompleted();
            yield break;
        }

        var uri = string.Format(@"http://usericon.nimg.jp/usericon/{0}/{1}.jpg", userId / 10000, userId);
        var www = new WWW(uri);
        yield return www;

        if (!string.IsNullOrEmpty(www.error))
        {
            texture2Cache[userIdStr] = null;
            yield break;
        }

        texture2Cache[userIdStr] = www.texture;
        observer.OnNext(www.texture);
        observer.OnCompleted();
    }
}

(コルーチン内部で条件分岐させているけど、これSelectMany内で条件分岐してObservable.Returnした方がよかったかもしれない…)

一度取得したテクスチャはメモリにキャッシュしておき、同じリクエストを何回も飛ばさないようにしています。

デスクトップの取り込み

凹さんの作成されたuDesktopDuplicationを使うとWindowsのデスクトップ画面をUnity上でテクスチャとして扱うことができるようになります。

参考: Unity で Windows のデスクトップ画面をテクスチャとして表示するプラグインを作ってみた

これを使ってデスクトップ画面を取り込んでいます。

用途1:コメビュをみる

VRゴーグルをつけてゆかりさんになっている以上、生放送のコメント欄を確認することができません。
なのでコメビュのウィンドウをキャプチャしてVR内に表示することで、コメントの確認をできるようにしました。

image.png
(平たくしたCubeにWindowを描画しているテクスチャを貼り付けることで、コメント一覧パネルをVR空間上に作った)

用途2:ニコニコ動画をみる

VR空間にテレビを配置して、そのテレビにWindowを貼り付けることでテレビを見る感覚でデスクトップを眺めることができるようになります。
そこでニコニコ動画の動画を引用して再生すればみんなで動画をみることができるようになりました。

image.png
(けものフレンズはチャンネル動画なので生放送で引用すると怒られるかもしれないです…。引用するならユーザ投稿動画にしたほうがよかったですね。)

用途3:ゲームで遊ぶ

未検証です。遅延が問題ないレベルなら遊べるかもしれませんが、ゆかりごっこ+ゲームの同時起動になるのでよっぽど高スペックじゃないと難しいかもしれない。いつかやりたい。

感想

美少女になって生放送するの、なんとも言えない快感があるのでオススメです。ただし気を抜くとすぐおっさんくささが出るので注意。
あと喋り方と仕草で中の人が自分だとすぐ会社の人にバレました。

今後の展望

  • ゲーム実況できるようにしたい
  • コメント操作でカメラアングルとか変えられるようにしたい
  • ゆかりさん以外にもモデル増やしたい
  • Vive Trackerを買ったので組み込みたい

ついでに

コスプレVR生放送の先駆者としてみゅみゅさんという方とコラボ生放送もしました。
おっさん2人が集まって美少女のふりをしながらおっさんトークする地獄の様な動画なので、見る人は覚悟して見て下さい。

ゆかりごっこのゆかりさんとVR生放送

ついでに2

hiroshiba.kさんがDeepLearningでも声質変換したい!というチャレンジを行っていました。
こちらはディープラーニングを使って生声を直接ゆかりさんボイスに変換しちゃおうという試みです。
実用的になったらゆかりごっこに組み込みたい。

ついでに3

おっさんがVRで美少女になるシリーズ、「TSVR」って勝手に呼んでます。どうぞご利用ください。