バスキュール技術ブログ

バスキュールが得意とするインタラクティブエンジニアリングを、あますことなくお届け!

ARきぼう予報を作って宇宙飛行士に手を振った話

エンジニアの勝田です。こんにちは。

弊社で開発・運営している「#きぼうを見よう - 国際宇宙ステーションが見える予測日時をお知らせ」というサイトに新しく「ARきぼう予報」というWebAR機能を追加しました。

f:id:bascule-dev:20211019184944j:plain ARきぼう予報 | #きぼうを見よう - 国際宇宙ステーションが見える予測日時をお知らせ

国際宇宙ステーション(ISS)がどんな軌道を通るか、リアルタイムでどこにいるのかがわかるサービスです。
今日はそのAR機能についてお話したいと思います。

ISS観測について

上空付近をISSが通過するときにいくつかの条件がそろうと肉眼で観測することができます。
詳しくはこちらをご覧ください。

lookup.kibo.space

ISSは、

  • 明るい星のような強い光
  • 飛行機のようにチカチカせず
  • 飛行機よりも速いスピードで

見えます。
とても特徴的なので、一度見つければ「あれはISSだ!」とすぐにわかります。
ただ、見つけるまではなかなかわかりにくく、ISSかな?と思って見ていると星だったり飛行機だったりします。特に初めてISS観測を行う人の場合、雲が多かったり、仰角(ISSが見える高さ)が低いとどれがISSなのかわからず、本当にISSは見えるんだろうか…と不安な気持ちで空を眺めて待つことになります。

そんなとき、ARきぼう予報を使ってスマホを空にかざすと、いまどこにISSがいて、いつごろ見えるかがビジュアライズされているため簡単に観測ができるようになります!
こちらは開発中に撮影したキャプチャですが、こんな感じです。

モックを作る

3D構築にはThree.js、AR機能はスマホのカメラ、位置情報、ジャイロセンサーを使用しています。
今回はISSの座標をAR上に表示するため、Three.js上での方角と現実世界の方角を合致させる必要がありました。
そのため、まず、シンプルに真北にキューブを表示するだけのモックを作成することにしました。

モックの完成品はこちらからどうぞ。
github.com

f:id:bascule-dev:20211019185444g:plain

プレビューページ(PC非対応です)
https://yuko-katsuda.github.io/armock/index.html

3D環境のベースを作る

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8"/>
  <script type="module">
     import * as THREE from '<https://cdn.skypack.dev/three>';

    window.addEventListener('load', init);

    var $view;

    var width = window.innerWidth;
    var height = window.innerHeight;

    function init()
    {
      $view = document.querySelector('#view');
      setup3D();
    }

    function setup3D()
    {
      const renderer = new THREE.WebGLRenderer({alpha: true, canvas: $view});
      renderer.setPixelRatio(window.devicePixelRatio);
      renderer.setSize(width, height);

      const scene = new THREE.Scene();

      const camera = new THREE.PerspectiveCamera(80, width / height, 0.1, 100);
      camera.position.set(0, 0, 10);

      const geometry = new THREE.BoxGeometry(1, 1, 1);
      const material = new THREE.MeshNormalMaterial();
      const box = new THREE.Mesh(geometry, material);
      box.position.set(0, 0, 0);
      scene.add(box);

      update();
      resolve();

      function update()
      {
        requestAnimationFrame(update);
        renderer.render(scene, camera);
      }
    }

  </script>

</head>
<body>
  <canvas id="view"></canvas>
</body>
</html>

以下のページを参考にしています。

簡単なThree.jsのサンプルを試そう - ICS MEDIA

キューブの座標を(0, 0, 0)に設定していますが、z:0 = 現実世界の真北 にしたいので、キューブが常に真北に表示されるようになればモックの完成です。

デバイスの向きの取得

Three.jsのDeviceOrientationControlsを使用してデバイスの向きと3D上の世界を同期させます。

three.js docs DeviceOrientationControls

ですが、デフォルトのままだと、iOSでは起動時の向きがz:0に補正され、Androidでは起動するたびに0の向きが変わってしまいます。

絶対値で向きを取得するイベントに差し替える

deviceorientationabsoluteというイベントがあり、その名のとおり絶対値でデバイスの向きを取得できるAPIです。

Window.ondeviceorientationabsolute - Web APIs | MDN

まず、Three.jsのDeviceOrientationControlsクラスを複製して新しいjsを作ります。すべての内容を載せると長くなるので、ポイントのみ記述します。

なお、完成品のDeviceOrientationControlsクラスはこちらになりますので、先に載せておきます。
https://yuko-katsuda.github.io/armock/DeviceOrientationControls.js

複製したファイル内でdeviceorientationイベントを走らせているところを探し、そこをdeviceorientationabsoluteに書き換えました。
ライブラリのバージョンによって内容に若干違いはあるかもしれませんが、this.connect関数の中にあります。

変更前

this.connect = function () {

      onScreenOrientationChangeEvent(); // run once on load

      // iOS 13+

      if ( window.DeviceOrientationEvent !== undefined && typeof window.DeviceOrientationEvent.requestPermission === 'function' ) {

        window.DeviceOrientationEvent.requestPermission().then( function ( response ) {

          if ( response == 'granted' ) {

            window.addEventListener( 'orientationchange', onScreenOrientationChangeEvent );
            window.addEventListener( 'deviceorientation', onDeviceOrientationChangeEvent );

          }

        } ).catch( function ( error ) {

          console.error( 'THREE.DeviceOrientationControls: Unable to use DeviceOrientation API:', error );

        } );

      } else {

        window.addEventListener( 'orientationchange', onScreenOrientationChangeEvent );
        window.addEventListener( 'deviceorientation', onDeviceOrientationChangeEvent );

      }

      scope.enabled = true;

    };

変更後

this.connect = function () {

      onScreenOrientationChangeEvent(); // run once on load

      // iOS 13+

      if ( window.DeviceOrientationEvent !== undefined && typeof window.DeviceOrientationEvent.requestPermission === 'function' ) {

        window.DeviceOrientationEvent.requestPermission().then( function ( response ) {

          if ( response == 'granted' ) {

            window.addEventListener( 'orientationchange', onScreenOrientationChangeEvent );
            window.addEventListener( 'deviceorientation', onDeviceOrientationChangeEvent );

          }

        } ).catch( function ( error ) {

          console.error( 'THREE.DeviceOrientationControls: Unable to use DeviceOrientation API:', error );

        } );

      } else {

        window.addEventListener( 'orientationchange', onScreenOrientationChangeEvent );
        window.addEventListener( 'deviceorientationabsolute
', onDeviceOrientationChangeEvent );

      }

      scope.enabled = true;

    };

iOS Safariはdeviceorientationabsoluteに対応していません。
なので、以下のようにコードを書き換えました。

const ua = navigator.userAgent
if(/android/i.test(ua))
{
  window.addEventListener( 'orientationchange', onScreenOrientationChangeEvent, false );
  window.addEventListener( 'deviceorientationabsolute', onDeviceOrientationChangeEvent, false );
}
else
{
  window.addEventListener( 'orientationchange', onScreenOrientationChangeEvent, false );
  window.addEventListener( 'deviceorientation', onDeviceOrientationChangeEvent, false );
}

出来上がったDeviceOrientationControlsクラスをimportして、setup3D関数でコネクトします。

ライブラリのimport

    import * as THREE from '<https://cdn.skypack.dev/three>';
    import { MathUtils } from '<https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.module.js>';
    import { DeviceOrientationControls } from './DeviceOrientationControls.js';

setup3D関数

    function setup3D()
    {
      const renderer = new THREE.WebGLRenderer({alpha: true, canvas: $view});
      renderer.setPixelRatio(window.devicePixelRatio);
      renderer.setSize(width, height);

      const scene = new THREE.Scene();

      const camera = new THREE.PerspectiveCamera(80, width / height, 0.1, 100);
      camera.position.set(0, 0, 10);

        // DeviceOrientationControlsをコネクトさせる
      var controls = new DeviceOrientationControls(camera);
      controls.connect();

      const geometry = new THREE.BoxGeometry(1, 1, 1);
      const material = new THREE.MeshNormalMaterial();
      const box = new THREE.Mesh(geometry, material);
      box.position.set(0, 0, 0);
      scene.add(box);

      update();

      function update()
      {
        requestAnimationFrame(update);
        renderer.render(scene, camera);

        // コントロールを更新
        controls.update();
      }
    }

iOSでの向きを地球軸にする

iOSではThree.jsを実行した向きが0として補正されてしまいます。
なので、ページを表示した瞬間のalpha値をalphaOffsetに渡し、強制的に補正前に戻すようにしました。

      // DeviceOrientationControlsをコネクトさせる
      var controls = new DeviceOrientationControls(camera);
      controls.connect();

      // iOSのとき、一度だけalphaOffsetに補正値を渡す
      if(os == 'ios')
      {
        var deg = 0;
        var diff = null;
        var doneCalibration = false;

        window.addEventListener('deviceorientation', (e)=> {
          deg = e.webkitCompassHeading;
          if(deg != 0 && diff == null)
          {
            diff = deg;
          }

          if(!doneCalibration)
          {
            doneCalibration = true;
            controls.alphaOffset = MathUtils.degToRad( - diff );
          }

        }, true);
      }

カメラの取得

続いてカメラ映像の取得です。
Media Streams APIを使用してストリームを取得し、htmlのvideo要素に渡しています。

    function setupCamera()
    {
      return new Promise((resolve, reject)=>{
        window.mediaDevices = navigator.mediaDevices || ((navigator.mozGetUserMedia || navigator.webkitGetUserMedia) ?
        {
          getUserMedia: (c)=> {
            return new Promise((y, n)=> {
              (navigator.mozGetUserMedia ||
              navigator.webkitGetUserMedia).call(navigator, c, y, n);
            });
          }
        } : null);

        const mediaConstraints = {
          audio: false,
          video: {
            width: 1920,
            height: 1080,
            facingMode: { exact: 'environment' }
          }
        };

        window.mediaDevices.getUserMedia(mediaConstraints)
        .then((stream)=>
        {
          $video.srcObject = stream;

          $video.onloadedmetadata = (e) =>
          {
            $video.play();
            resolve();
          };
        })
        .catch((err) =>
        {
          reject();
        });
      });
    }

「MediaDevices.getUserMedia() 」について - QiitaMediaDevices.getUserMedia() - Web API | MDN

位置情報の取得

位置情報の取得にはGeolocation APIを使用しています。
getCurrentPositionというメソッドを実行すると、latitude(緯度)、longitude(経度)などの値が返ってきます。

    function setupGeo()
    {
      return new Promise((resolve, reject)=>{
          navigator.geolocation.getCurrentPosition((data)=>{
            resolve(data);
          },
          (e)=>{
            reject(e);
          });
        }
        catch(e)
        {
          reject(e);
        }
      }
    }

Geolocation.getCurrentPosition() - Web API | MDN

これで3つの機能の取得ができました。

デバイスの許可フローを作る

deviceorientationを取得するためには、ユーザーになんらかのアクションをさせ、そのイベント内で許可フローを実行しなければなりません。
また、カメラで取得した映像をJavaScriptから再生させるためにも事前のユーザーインタラクションが必要になるので、以下のようにボタン押下イベントを起点とする許可フローを作成しました。

    function init()
    {
      // html要素を取得
      $view = document.querySelector('#view');
      $video = document.querySelector('#video');
      $button = document.querySelector('#button');

      // ボタンをクリックするとonClickを実行
      $button.addEventListener('click', ()=>{
        onClick();
      });
    }

    function onClick()
    {
      // ボタン押下後、非表示にする
      $button.classList.add('hide');

      // 3D、カメラ、位置情報を順番に取得していく
      setup3D(()=>{
        Promise.resolve()
          .then( () => setupCamera() )
          .then( () => setupGeo() )
          .then( (data) => {
            console.log(data);
          })
        ;
      });
    }

ARきぼう予報では、ジャイロ、カメラ、位置情報の3種類すべてを使用しているため、それぞれの機能をPromiseで繋いでいます。

なお、モックでは端折っていますが、ユーザー側での拒否や端末の非対応など何らかのエラーが発生するとrejectに繋いでヘルプページに遷移するようにしていました。

これでモックの完成です。
このように、ジャイロ、カメラ、位置情報の3つを同期させ、さらに、ISSの位置情報や、地球の自転、公転による天体との位置関係を組み合わせることでARきぼう予報を実現しています。

youtu.be

どこまで機能するか?

ジャイロの精度が端末ごとにかなりぶれがあり、完璧ではないという問題がありました(特にAndroid)
比較的安定しているiOSも、deviceorientationのずれを補正するのに起動直後の値を使用しているため、AR実行時にスマホを持つ手が大きく揺れていたりするとずれてしまう可能性がありました。

デバイスのセンサーを取得しているため、デバイス側がずれているとどうしようもないわけです。そのような限界がある中で、ISSの観測ツールとしてARきぼう予報がどこまで機能するか?という不安がありました。
ちなみにISSは毎日見えるわけではありません。条件が合わないと1ヶ月近く肉眼で見えるタイミングがなかったりします。
なかなかISS観測をするチャンスがないまま、開発を進めていました。

開発版のチェック

きぼうを見ようチームのメンバーは関東と関西それぞれ住んでいるところはバラバラです。
AR機能がある程度動作するようになったころ、全国的にISSを観測できる好条件がそろった日があり、開発版をチームでチェックすることになりました。
なお、この時点で私はARを使ったISSの観測が一度もできていません。使い物にならなかったらどうしよう…とドキドキしながらチェックに臨みました。

結果、少しずれはありましたが、ARを使うことでこれまでよりも格段にISSの観測がしやすくなることがわかりました。
ISSは弱い光が自分の真上に近づくにつれて徐々に強い光へと変わっていくのですが、かなり弱い光の時点からISSを発見することができました。リアルタイムのISSの軌道がAR上に表示されているため、肉眼で確認するよりもずっと早くISSが昇ってくる位置の目星がつけられるのです。これなら誰にでも簡単にISSを見つけることができると確信しました。

ちなみにこの日、「みんなで #きぼうを見よう LIVE」というTwitter Spacesで全国の方とお話しながらISSを観測するイベントがKIBO宇宙放送局Twitterアカウントで行われていたのですが、LIVEの前に星出彰彦宇宙飛行士からこんなツイートがありました。

地上とISSは約400km以上離れています。地上からはISSは星の光のように見えます。
あの光の場所に人がいるんだなあと思うと、なんだか信じられないような不思議な感動があります。そして、いま私と同じように空を眺めている人たちが日本中にいて、会ったことはないけど同じ感動を共有してるってすごいことだと思いました。
(その感動は、「今見上げている人数」という機能に引き継がれました)

星出宇宙飛行士もそんな気持ちだったらいいなあと思いながら、いっぱい手を振りました!

youtu.be

そのときのTwitter Spacesの様子がこちらです。
全国のみなさんといっしょに空を渡るISSを探しながら、星出宇宙飛行士に手を振った、すてきな時間でした。

「みんなで #きぼうを見よう LIVE」はときどき開催しておりますので、ぜひAR機能を使って参加してみてください!肉眼で見えるISSは本当に綺麗です。
KIBO宇宙放送局のTwitterで随時お知らせ中です。

KIBO宇宙放送局 (@KIBO_SPACE) | Twitter

エンジニア募集してます!

そして、バスキュールではただいま新しい仲間を募集しています。
まだ誰も体験したことのないようなものを作る感動を味わってみたいという方、一緒に働きませんか?
ぜひぜひご応募お待ちしております!! bascule.co.jp