WebRTC
reactjs
VR
SkyWay
ReactVR

SkyWay(WebRTC)とReactVRで次世代コミュニケーションをHackする

就活とか研究会とか終わったのでアウトプットターム。まずは半年前のネタ。 鮮度は低い

2017年の12月ごろにNTTコミュニケーションズ様・CodeSprint様主催のSkyWayオンラインハッカソンに出て、ありがたく最優秀賞いただけたので詳細を書きます。

ハッカソンLP

https://codesprint.jp/lp/nttcomm-lp/

ぎふはぶ

https://github.com/shalman/SkywayVR

デモ

skyway-react.gif

https://shalman.github.io/SkywayVR/app/vr/build/

スマホだとバレルディストーションで、ジャイロから回転できます。

頷き(肯定)と首振り(否定)くらいの簡単なノンバーバルコミュニケーションならできます。もちろん音声通信も。

一人で確認する場合は2画面にして行う。
ホストで入り、「Click to dump」をクリックすると、コンソールにridが表示されるのでコピーし、ゲストでもう一方の画面で入力して入室する。

SkyWay

https://webrtc.ecl.ntt.com/

NTTコミュニケーションズが開発しているWebRTCライブラリ。個人用途(Community Edition)だと無料。

WebRTCは、Web Real-Time Communicationの略。PCにくっついてるWebカメラやマイクを使ってブラウザ上でコミュニケーションができる規格。

Skypeと引き合いに出されることが多いが、 アプリ不要でブラウザからURL叩けばすぐ始められることがメリット。 人にはAppear.inみたいなやつ。と説明している。

WebRTCは基本P2Pであり、お互いのIP知る必要があって、STUNサーバとか立てないといけないとかそこらへんの面倒をSkyWayが見てくれる。いい感じに難しいところを隠蔽化してくれて、組み込む開発者側のコストが低いと、全体を振り返って思う。

「次世代コミュニケーション」のブレスト

テーマは「次世代コミュニケーション」とのことでした。ちなみに1人開発。

今回のハッカソンの審査がどのように行われているか分かりませんが、「テーマに沿っているか」というパラメータは往々にしてある気がします。なので結構意識しました。
(ちなみに、新規性・インパクト・有用性(金になりそうか)も毎回評価対象になってる気がします。アイデアソンだと実現可能性も。)

大学院でVRとかHCIに関する研究を行っているので、こちらのマッシュアップを最初に考えました。

VRとWebRTCというと、まずテレイグの同期が浮かんだんですが、すでにNTTComの中の方(大御所・舘研)が研究されていました。

オンラインハッカソンの期間も1週間くらいだったので、プロトがさっと作れるものにフォーカスして、シンプルにVR空間でコミュニケーションできるシステムを作ることに決定。

ReactVR(現 React360)

https://facebook.github.io/react-360/

VR空間の作成は、大抵UnityかUnrealEngineなわけなんですが、ハッカソンの縛りで確かJSのライブラリ限定だったので、WebVRという選択を取りました。

WebVRというと、MozillaのA-Frameが有名なわけですが、2017年はインターンとかでReact書いていたので、親和性のあるReactVRを選定しました。

ちなみに、今はReact360という名前に再ブランディングしているみたいです。本稿ではReactVRのまま進めます。

要件定義

  • スマートフォン(iOS.safari)をHMDとし、VR空間で他のユーザとリアルタイムコミュニケーションが行える。
    • コミュニケーション方法は、音声を用いるが、カメラは使用せず、アバターを介する。
    • スマートフォンのジャイロセンサを用いて、頭部回転情報を共有し、ノンバーバルコミュニケーションを含む。
    • 簡単なコミュニケーション法として、視線で気持ちをつたえる「いいね!」が行える。
    • VR空間はユーザの好みに合わせて複数のシーンから選択できる。
  • VR空間の実装はReactVRを用いる。
  • WebRTCライブラリはSkyWayを用いる。

新規性を出すために、VR空間でのコミュニケーションでのスタンプを提案しました。

実装

ReactVRとWebWorker

ReactVRはReactNativeベースです。スタックが似ているのですが、メインスレッドでは、ReactVRのランタイムや、three.jsが動いている一方、VR空間を作成するReactApplicationはWebWorker上で動いています。

ご存知の通り、JSはシングルスレッドで動くわけですが、マルチスレッドを実現するのがWebWorkerなわけですね。

ReactVRではWebWorkerで動かすことに非常に意味があります。それは 「VR酔い」 対策です。

ReactVRをVRたらしめている理由は、2画面モードや3D空間の作成のみならず、スマホやOculusなどのHMDの回転に合わせて景色を変化させていることにあります。右を向けば、右の景色が見えるということが、VRの感動を生んでいます。

しかし、頭部回転->ジャイロセンサ値取得->レンダーまでのディレイが大きいと、人間が期待している景色と実際に見ている景色に矛盾が生じ、この場合だと気持ち悪く感じます。これがVR酔いです。
(この現象をうまく使うことで、クロスモーダル知覚を起こして、臨場感を高めたりする手法もありますが割愛)

従って、ディレイなくすためにマルチスレッドで行ったほうがいいというわけですね。そのためのWebWorkerなわけです。今まであまり使い所がなかったWebWokerがWebVRで生きるという話でした。

ReactVRとSkyWay

WebRTCを使ったシステムにはgetUserMediaを呼び出すことから始まります。

console
navigator.mediaDevices.getUserMedia({video: true, audio: true})

WebWorkerで動く場合、そこで呼び出すnavigatorはWorkerNavigatorなので、getUserMediaが呼べません。

なので、worker外で処理しないといけないのですが、得た情報の応じてVR空間も動的に変化させたいです。そこで、bridgeからメッセージを送受信します。

client.jsは以下の通りです。

client.js
function init(bundle, parent, options) {
  //iOS11でも2眼モード(一部機能せず)
  //https://qiita.com/shalman/items/ee576fa28e763ce83bdf
  WebVRPolyfill.InstallWebVRSpecShim();

  //パノラマ設定
  const panoSearch = location.search.match(/pano=(.*?)(&|$)/);
  const pano = panoSearch? panoSearch[1] : 'lake.jpg';

  const vr = new VRInstance(bundle, 'SkywayVR', parent, {
    // Add custom options here
    //レイを飛ばす
    raycasters: [
      {
        getType: () => 'mycursor',
        getRayOrigin: () => [0, 0, 0],
        getRayDirection: () => [0, 0, -1],
        drawsCursor: () => true
      }
    ],
    cursorVisibility: 'visible',
    initialProps: { 'pano': pano},
    ...options,
  });
  vr.render = function() {
    // Any custom behavior you want to perform on each frame goes here
  };
  // Begin the animation loop
  vr.start();

  //Workerの取得とBridgeの作成
  const VRWorker = vr.rootView.context.bridge.getWorker();
  window.SkyWayBridge = new SkyWayBridge(VRWorker);

  return vr;
}

window.ReactVR = {init};

iOSでデモしたかったので、PolyfillでiOSを対応しました。

SkyWayBridgeというクラスを作り、このクラスでSkyWayの受け答え処理します。
Workerはvr.rootView.context.bridge.getWorker();で得られるので、SkyWayBridgeにコンストラクタに突っ込み、インスタンスをグローバル空間に置いておきます。

ホスト・ゲスト処理

アプリケーションのシーケンスは、

ホストが入室->ゲストにuidを伝える->ゲストがuidを入力し、入室->コミュニケーション開始

を考えました。

まず、ホストが入室したときのReactVR側、componentDidMountです。

index.vr.js
  _postMessage(msg) {
      //Skyway(メインスレッド)へのメッセージ送信 
      msg.from = 'react-vr';
      window.postMessage(JSON.stringify(msg));
  }
  componentDidMount() {
    //マウント後にUserMediaを取得しないと,window.SkyWayBridgeが存在しない
    let msg = {
      'action': 'GET_USER_MEDIA',
    };
    this._postMessage(msg);
    this._watchHeadRotation();

  }

コンポーネントが立ち上がったら、まずgetUserMediaを呼んで設定し始めます。
postmessageでメインスレッドにメッセージを送ります。裏で多くのメッセージが飛び交ってるので、msg.fromにreact-vrを入れておいて識別し、actionをキーとして、アクション名を送信し、受け側でSwitchで分岐。ここらへんはちょっとreduxっぽいかも。

SkyWayBridge側で受けます。

SkyWayBridge.js
    this._worker.addEventListener('message', (e) => {
      try {
        let msg = JSON.parse(e.data);
        if(msg.from === 'react-vr') {
          switch(msg.action) {
            case 'GET_USER_MEDIA':
              this.getUserMedia();
              break;
            case 'COPY_ID':
              this.copyId();
              break;
            case 'SEND_HEAD_ROT':
              this.sendHeadRot(msg.rot);
              break;
            case 'LIKE':
              this.sendLike();
              break;
            default:
              break;
          }
        }
      }catch(e) {
        return;
      }
    });

streamをもらって、メンバに入ときます。
ゲストの場合、URIパラメータにridがくっついていて判別します。この場合、DataConnectionとMediaConnectionの接続を行います。

SkyWayBridge.js
  getUserMedia() {
    navigator.mediaDevices.getUserMedia({audio: true})
      .then( (stream) => {
        // Success
        this._localStream = stream;
        const msg = {
          'info': 'succeeded getting mediaDevice',
        };
        this.postMessage(msg);

        //Connect the peer if rid exists.
        const search = location.search.match(/rid=(.*?)(&|$)/);
        const rid = search ? search[1] : '';
        if(rid){
          const call = this._peer.call(rid, stream);
          this.setupCallEventHandlers(call);

          const dataConn = this._peer.connect(call.peer);
          this.setupSendDataEventHandler(dataConn);
        }

      }).catch(function (error) {
      // Error
      console.error('mediaDevice.getUserMedia() error:', error);
    });
  }

peer.callで呼び出し、callをメンバとして保持します。
ReactVRでは、音源の位置を指定することによって、3Dサウンドを実装することができるのですが、今回はVideoタグの実DOMを裏でくっつけて音声が聞こえるようにしました。

そしてplaysinlineの罠を回避。
接続完了をReactVRに伝える。

SkyWayBridge.js
  setupCallEventHandlers(call) {
    if(this._mediaConn) {
      this._mediaConn.close();
    }

    this._mediaConn = call;


    //When you get friend's stream
    call.on('stream', (stream) => {
      //ReactVRのSoundComponentを使うためには,WorkerにJSON化してstreamを送らないといけないので
      //今回はメインスレッドにて実DOMでVideoタグを作成
      const body = document.getElementsByTagName("body").item(0);
      const videoEl = document.createElement('video');
      videoEl.srcObject = stream;
      //Safari 11だとautoplayに対応していない.playsinline系が必須.
      //https://gist.github.com/voluntas/af937c1fd353e6f677e155b53d661807
      videoEl.setAttribute('webkit-playsinline', true);
      videoEl.setAttribute('playsinline', true);
      videoEl.setAttribute('autoplay', true);
      body.appendChild(videoEl);
      videoEl.play();

      this.postMessage({
        action: 'CONNECTED',
        friend_id: this._id,
      });


    });

    call.on('close', () => {
      console.log('disconnected!');
      //When friend or you closed stream
    });
  }

ホストは、peerの'call'と'connection'イベントをリッスンして待ち、ゲスト同様dataとmediaのコネクションを設定します。

頭部回転情報共有

ノンバーバルコミュニケーションが売りなので、しっかり相手が頭を回転したらこっちのアバターにも反映してもらい、逆にこちら側の回転を送る必要があります。

このような情報はDataConnectionで簡単に実装できます。

自分の頭部回転を送る

componentDidMountで呼んでいたジャイロ監視関数です。

index.vr.js
  _watchHeadRotation() {
    this.setInterval(() => {
      const rot = VrHeadModel.rotation();
      this._postMessage({
        action: 'SEND_HEAD_ROT',
        rot: rot,
      });
    }, this._avatarReflesh);
  }

ReactVRにはUnityのようなイベントループがないので、古典的なsetIntervalでジャイロセンサを監視しました。(もっといい方法がありそう)

センサへのアクセスはVrHeadModel.rotation()で簡単に取得できます。
SkyWayBridgeに投げて、送信準備します。

また、ES6のReactでsetIntervalを使うためにはreact-mixreact-timer-mixinが必要なので、importしてから以下を走らせます。

index.vr.js
ReactMixin.onClass(SkywayVR, ReactTimerMixin);

メッセージをリッスンしているので、sendHeadRotが呼ばれ、左から右へ受け流します。

SkyWayBridge.js
  sendHeadRot(rot) {
    if(this._dataConn && rot) {
      this._dataConn.send({
        type: 'rot',
        rot: rot
      });
    }
  }

相手の頭部回転情報を受けとり、アバターに反映する。

送られてきた情報はDataConnectionの'data'イベントが呼ばれるので、そのまま、x,y,zに分けてReactVRにメッセージとして返します。

SkyWayBridge.js
    dataConn.on('data', (data) => {
      switch(data.type) {
        case 'rot':
          const rot = data.rot;
          this.postMessage({
            action: 'RETURN_FRIENDS_HEAD_ROT',
            rot: {
              x: rot[0],
              y: rot[1],
              z: rot[2],
            }
          });
          break;
        case 'like':
          this.postMessage({
            action: 'RETURN_FRIENDS_LIKE'
          });
          break;
        default:
          break;
      }
    });

ReactVR側で受け取ります。モデルに合わせた座標変換をしてStateを更新します。

index.vr.js
      case 'RETURN_FRIENDS_HEAD_ROT':
        if(this.state.status === this.Status.CONNECTED) {
          this.setState({
            avatar_rot: {
              x: -msg.rot.x,
              y: msg.rot.y + 180,
              z: msg.rot.z + 180,
            },
          });
        }
        break;

あらかじめtransformをStateでbindしてあったモデルが回転するという流れです。

スタンプ機能(いいね機能)

skyway-react-like.gif

視線でコミュニケーションができればいいなと思った機能です。

まず視線はFOVEのように、スマホではトラッキングできないので、便宜的に頭部の向いている方向と同様の方向を見ていると仮定します。よく使われる手法です。

レイキャストして、カーソルを表示し、スタンプを見たらトリガーするという流れにしました。

client.js
  const vr = new VRInstance(bundle, 'SkywayVR', parent, {
    // Add custom options here
    //レイを飛ばす
    raycasters: [
      {
        getType: () => 'mycursor',
        getRayOrigin: () => [0, 0, 0],
        getRayDirection: () => [0, 0, -1],
        drawsCursor: () => true
      }
    ],
    cursorVisibility: 'visible',
    initialProps: { 'pano': pano},
    ...options,
  });

いいね!を送る

まず、対象となるボタンを用意し、onEnterでトリガーします。

index.vr.js
        <Image source={ asset('like_button.png') } style={{
          height: 0.6, width: 2,
          transform: [
            { translate: [-4, 2.5, -3] },
            { rotateY: 25 }
          ],
        }} onEnter={ this._gazeLike.bind(this)} >
        </Image>

そして、SkyWayBridgeにLIKEアクションを投げる。シンプルです。

index.vr.js
  _gazeLike() {
    console.log('いいね!しました');
    this._postMessage({
      'action': 'LIKE'
    });
  }

SkyWayBridge側では、頭部回転情報共有同様、左から右へ受け流します。

いいね!を受け取る

_getLikeが呼ばれます。

index.vr.js
  _getLike() {
    console.log('いいね!されました');

    const likeHeadRot = {
      x: -14.67994310099575,
      y: 218.1507823613087,
      z: 180
    };

    this.setState({
      status: this.Status.GET_LIKE,
      avatar_rot: likeHeadRot
    });

    Animated.spring(this.state.likeBounceValue, {
      toValue: 1.5,
      friction: 4,
    }).start();

    //いいね解除
    this.setTimeout(() => {
      this.setState({
        status: this.Status.CONNECTED,
      });

      Animated.spring(this.state.likeBounceValue, {
        toValue: 0.1,
        friction: 4,
      }).start();

    }, this._likeIntervalTime);

  }

どのように演出するかですが、サムザップアニメーションで、チャラいレゲエホーンを鳴るようにしました。(結構これがデモで受けた気がする。。)

まず、おっさんアバターがいい感じの斜めに傾いてもらいます。いろいろ調整した定数がlikeHeadRotです。

そこから facebookのような 親指を突き出した「いいね!」画像をspringアニメーションを発火し、setTimeoutでもどします。

音が鳴ってるかどうかはstateで管理しました。

index.vr.js
<Sound source={ asset('horn.mp3') } autoPlay={ false } playControl={ this.state.status === this.Status.GET_LIKE ? 'play' : 'stop'} />

もうちょっとやりたかったこと・できそうなこと

  • アバターが無表情なので、faceUpdator的なやつを作りたかったが、ReactVRでは難しそうだった。 UnityのMacanimとかAnimatorみたいな感じで簡単にアニメーションできればWebVRもやりやすそう。
  • Soundコンポーネントを使った3Dサウンド。
  • ridの入手プロセスが煩雑なので、なんとかできないかなと。

ハッカソンを通じて得たもの

  • WebWorker周りの知識
  • WebVRの一通りの機能が実装できた実績
  • SkyWay,WebRTCの実装経験
  • fitbit alta(優勝賞品)
  • 特別オファー

所感

  • 簡単にWebRTCが実装できる
  • ICEとかNAT超えとか全く気にしていないのでブラックボックス化が半端ない
  • WebRTCに興味を持ったので、新しいユースケースを提案して作ってきたい

未来

ここからポエムです。

WebもVRもドッグイヤーであり、常に変化する技術です。React360にリブランディングされたのがこのことをよく表しています。

その双方の瞬間を切り取って一つのアプリケーションに落とし込めたのは今しかできないことだと思い、開発してよかったなと感じています。

WebVR API(getVRDisplaysなど)に関してはWebXRになりそうです。

XRへの変化は2017年のUnityAPIがVRからXRに変更された背景もあるのではないかなと思います。XRとは、「なんちゃらリアリティ」の総称であり、代表的なのはAR/VRなのですが、MRやDRも含まれているかと思います、たぶん。

機能に関しては、VRChatの方が断然上ですが、WebVRの良さはインストール不要というところでしょう。
ここらへんはPWAの良さにも近いです。あと余談ですが、今年はPWAで一つなにかサービスを作りたいと思います。(特にServiceWorkerに興味がある。)

WebVRの盛り上がりは正直感じませんが(特にReactVR・360は)、個人的にはWebにもVRにも興味があるので引き続きウォッチしていきたいと思います。

コーポレートサイトのように会社がVR空間を持ち、リクルート"空間"から即面接というサービスが当たり前になって、古典的ウェブページが、レスポンシブ対応の需要があったように、VR対応の需要が増せば新しいWebの未来が来るのでは?と妄想するのも面白いです。

また、ソーシャルVRというのも需要の高まりを感じます。VTuberを始め、3Dモデルをアクターとして見なせるだけの高品質なものも多く出てきたことが要因にあるように思えます。SecondLifeが超えられなかった先をVRChatなどのアプリがぜひ超えてほしいです。

ソーシャルVRをUnityで実装する場合、UNETというNetworkAPIでオンラインゲームの構築・マルチユーザとの通信を行うのですが、これがなかなか煩雑でした。
最近、SkyWayからブラウザなしでWebRTCする、WebRTC Gatewayが最近公開されているので、こちらにreplaceできるか手を動かしてみようかと思います。

参考

ReactVRでつくろう サーバ連動型インタラクティブ VR