0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Three.jsのDeviceOrientationControlsをCapacitorネイティブアプリに組み込んでARカメラビューを実装する方法

0
Posted at

ARカメラビューの実装は、Webベースの技術スタックでは意外と落とし穴が多い。特に、Three.jsのカメラ姿勢をデバイスのセンサーデータと同期させながら、CapacitorでiOS/Androidに配布するという構成は、それぞれの技術レイヤーの間で起きる問題を丁寧に潰していく必要がある。

本記事では、CityCanvas(位置情報ARプラットフォーム)の実装で得た知見をもとに、Three.jsのカメラ姿勢制御をDeviceOrientation APIとCapacitorセンサーAPIの両方に対応させる方法を深く掘り下げる。


全体のアーキテクチャ

ARビューを構成する主要レイヤーは以下の3層になる。

┌──────────────────────────────────────────┐
│   Three.js Renderer(WebGLRenderer)     │  ← 3Dオブジェクト描画
├──────────────────────────────────────────┤
│   Camera Orientation Bridge              │  ← センサー↔カメラ姿勢変換
├──────────────────────────────────────────┤
│   Sensor Input                           │
│   ├─ DeviceOrientation API(PWA/Web)    │
│   └─ Capacitor Motion Plugin(Native)  │
└──────────────────────────────────────────┘

重要なのは、センサーレイヤーを抽象化して上位レイヤーに統一したインターフェースを渡すことだ。これをしないと、WebとネイティブでARビューのコードが二重管理になる。


DeviceOrientation APIからThree.jsカメラへの変換

まずWebベース(PWA)の場合から始める。

DeviceOrientationEventが渡してくるalphabetagammaは、デバイスのオイラー角(ZXY順)だ。Three.jsのPerspectiveCameraはワールド座標系のクォータニオンで姿勢を管理しているため、直接代入はできない。また、スクリーンの向き(portrait/landscape)による補正も必要になる。

import * as THREE from 'three';

// デバイス座標系→Three.js座標系への変換に必要な定数クォータニオン
const Q0 = new THREE.Quaternion();
const Q1 = new THREE.Quaternion(-Math.sqrt(0.5), 0, 0, Math.sqrt(0.5));

class DeviceOrientationBridge {
  constructor(camera) {
    this.camera = camera;
    this.euler = new THREE.Euler();
    this.quaternion = new THREE.Quaternion();
    this.screenOrientation = 0;
    this._onDeviceOrientation = this._onDeviceOrientation.bind(this);
    this._onScreenOrientationChange = this._onScreenOrientationChange.bind(this);
  }

  connect() {
    window.addEventListener('deviceorientation', this._onDeviceOrientation);
    window.addEventListener('orientationchange', this._onScreenOrientationChange);
    this.screenOrientation = window.orientation || 0;
  }

  disconnect() {
    window.removeEventListener('deviceorientation', this._onDeviceOrientation);
    window.removeEventListener('orientationchange', this._onScreenOrientationChange);
  }

  _onScreenOrientationChange() {
    this.screenOrientation = window.orientation || 0;
  }

  _onDeviceOrientation(event) {
    const { alpha, beta, gamma } = event;
    if (alpha === null) return;

    // DeviceOrientationEvent → Three.js座標系への変換
    // ZXY順のオイラー角をクォータニオンに変換
    this.euler.set(
      THREE.MathUtils.degToRad(beta),    // X軸回転(前後傾き)
      THREE.MathUtils.degToRad(alpha),   // Y軸回転(方位角)
      -THREE.MathUtils.degToRad(gamma),  // Z軸回転(左右傾き)
      'YXZ'
    );

    this.quaternion.setFromEuler(this.euler);

    // Three.jsのカメラ座標系補正(デバイスがZ軸上を向いている基準→カメラはY軸が上)
    this.quaternion.multiply(Q1);

    // スクリーン回転補正
    const screenQuat = new THREE.Quaternion();
    screenQuat.setFromAxisAngle(
      new THREE.Vector3(0, 0, 1),
      -THREE.MathUtils.degToRad(this.screenOrientation)
    );
    this.quaternion.multiply(screenQuat);

    this.camera.quaternion.copy(this.quaternion);
  }
}

Q1の補正クォータニオンが必要な理由を説明しておく。DeviceOrientation APIは「デバイスが水平に置かれている状態をゼロ点」としてZ軸を上向きに定義している。一方でThree.jsのカメラは「正面を向いている状態でY軸が上」となっている。この座標系のギャップを埋めるのがQ1 = (-sqrt(0.5), 0, 0, sqrt(0.5))、つまりX軸周りに-90度回転するクォータニオンだ。


Capacitor Motionプラグインへの切り替え

ネイティブアプリ(iOS/Android)ではDeviceOrientationEventの精度が低くなる場合があるほか、iOSではユーザーの許可ダイアログが必要になる。Capacitorの@capacitor/motionプラグインはネイティブのセンサーAPIに直接アクセスでき、より高精度なデータを返す。

インターフェースを統一するため、同じconnect/disconnectAPIを持つアダプターを実装する。

import { Motion } from '@capacitor/motion';

class CapacitorMotionBridge {
  constructor(camera) {
    this.camera = camera;
    this.quaternion = new THREE.Quaternion();
    this._orientationHandler = null;
  }

  async connect() {
    // @capacitor/motionはaddListener形式でイベントを受け取る
    this._orientationHandler = await Motion.addListener(
      'orientation',
      (event) => this._handleOrientation(event)
    );
  }

  async disconnect() {
    if (this._orientationHandler) {
      await this._orientationHandler.remove();
      this._orientationHandler = null;
    }
  }

  _handleOrientation(event) {
    // Capacitor Motionは alpha/beta/gamma を返す(DeviceOrientation互換)
    // ただし単位はラジアンではなく度数のままなのでdegToRadが必要
    const { alpha, beta, gamma } = event;

    const euler = new THREE.Euler(
      THREE.MathUtils.degToRad(beta),
      THREE.MathUtils.degToRad(alpha),
      -THREE.MathUtils.degToRad(gamma),
      'YXZ'
    );

    this.quaternion.setFromEuler(euler);
    this.quaternion.multiply(
      new THREE.Quaternion(-Math.sqrt(0.5), 0, 0, Math.sqrt(0.5))
    );

    this.camera.quaternion.copy(this.quaternion);
  }
}

// 実行環境を判定してブリッジを切り替えるファクトリ関数
function createOrientationBridge(camera) {
  // Capacitor環境かどうかはwindow.Capacitorで判定
  if (window.Capacitor && window.Capacitor.isNativePlatform()) {
    return new CapacitorMotionBridge(camera);
  }
  return new DeviceOrientationBridge(camera);
}

ポイントはcreateOrientationBridgeのファクトリ関数だ。上位のARビューコンポーネントはどちらのブリッジを使っているかを意識せず、connect()disconnect()を呼ぶだけで動く。


ARシーンへのGPS連動オブジェクト配置

センサーからカメラ姿勢が取れたら、次はGPS座標にある3DオブジェクトをThree.jsのワールド座標に変換する必要がある。

GPS座標(緯度・経度)からワールド座標への変換は、ユーザーの現在地を原点としたメルカトル投影を使う。

// 地球の平均半径(メートル)
const EARTH_RADIUS = 6371000;

/**
 * GPS座標をThree.jsワールド座標に変換する
 * @param {number} userLat  - ユーザーの緯度(度)
 * @param {number} userLng  - ユーザーの経度(度)
 * @param {number} targetLat - ARオブジェクトの緯度(度)
 * @param {number} targetLng - ARオブジェクトの経度(度)
 * @param {number} altitude  - ARオブジェクトの高度(メートル)
 * @returns {THREE.Vector3}
 */
function gpsToThreeWorldPosition(userLat, userLng, targetLat, targetLng, altitude = 1.5) {
  const dLat = THREE.MathUtils.degToRad(targetLat - userLat);
  const dLng = THREE.MathUtils.degToRad(targetLng - userLng);

  // 球面上の距離をメートルで計算
  const x = dLng * EARTH_RADIUS * Math.cos(THREE.MathUtils.degToRad(userLat));
  const z = -dLat * EARTH_RADIUS; // Three.jsはZ軸が手前なので符号反転

  return new THREE.Vector3(x, altitude, z);
}

// 使用例:ARシーンへのオブジェクト追加
class ARScene {
  constructor(renderer, camera) {
    this.scene = new THREE.Scene();
    this.camera = camera;
    this.renderer = renderer;
    this.arObjects = new Map(); // id → THREE.Object3D
  }

  addARContent(content) {
    const { id, lat, lng, altitude, model } = content;

    // ユーザーの現在地はGeolocation APIで取得済みとする
    const position = gpsToThreeWorldPosition(
      this.userPosition.lat,
      this.userPosition.lng,
      lat,
      lng,
      altitude
    );

    // Meshy AIで生成したGLBモデルのロード
    const loader = new THREE.GLTFLoader();
    loader.load(model.url, (gltf) => {
      const mesh = gltf.scene;
      mesh.position.copy(position);

      // 遠距離オブジェクトはスケール調整(100m先でも視認できるよう)
      const distance = position.length();
      const scale = Math.max(1, distance / 20);
      mesh.scale.setScalar(scale);

      this.scene.add(mesh);
      this.arObjects.set(id, mesh);
    });
  }

  updateUserPosition(lat, lng) {
    this.userPosition = { lat, lng };

    // ユーザー移動時に全オブジェクトの座標を再計算
    this.arObjects.forEach((mesh, id) => {
      const content = this.contentStore.get(id);
      const newPosition = gpsToThreeWorldPosition(
        lat, lng,
        content.lat, content.lng,
        content.altitude
      );
      mesh.position.copy(newPosition);
    });
  }

  render() {
    this.renderer.render(this.scene, this.camera);
  }
}

scaleの自動調整は地味に重要で、これをしないと数百メートル先のオブジェクトが画面上でほぼ見えなくなる。Three.jsのパースペクティブ投影は物理的に正確なので、距離に応じたスケール補正を加えることで「見える」ARコンテンツが作れる。


iOSのDeviceOrientationPermission対応

iOS 13以降、DeviceOrientationEventにはユーザーの許可が必要になった。この処理はユーザーのタップ操作をトリガーにしなければならず、ページロード時に自動実行しても許可ダイアログが出ない。

async function requestDeviceOrientationPermission() {
  // DeviceOrientationEventにrequestPermissionが存在する場合のみ(iOS 13+)
  if (
    typeof DeviceOrientationEvent !== 'undefined' &&
    typeof DeviceOrientationEvent.requestPermission === 'function'
  ) {
    try {
      const permission = await DeviceOrientationEvent.requestPermission();
      return permission === 'granted';
    } catch (err) {
      console.error('DeviceOrientation permission denied:', err);
      return false;
    }
  }
  // Android・デスクトップは許可不要
  return true;
}

// UIボタンのクリックハンドラ内で呼ぶ
document.getElementById('start-ar-btn').addEventListener('click', async () => {
  const granted = await requestDeviceOrientationPermission();
  if (granted) {
    const bridge = createOrientationBridge(camera);
    await bridge.connect();
    startRenderLoop(bridge);
  } else {
    showPermissionError();
  }
});

Capacitorネイティブアプリでは@capacitor/motion側がパーミッション管理を持っているため、このWeb向け処理は不要になる。先述のファクトリ関数でブリッジを切り替えることで、この差異が自然に吸収される。


パフォーマンスに関する注意点

ARビューは60fpsを維持しながらセンサーデータを処理するため、負荷が高くなりやすい。実装上気をつけたポイントをいくつか挙げる。

クォータニオンオブジェクトの使い回し
センサーイベントは毎秒数十回発火する。_handleOrientationの内部でnew THREE.Quaternion()を毎回呼ぶとGCの負荷になる。クラスのコンストラクタでクォータニオンを生成し、copy()set()で使

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?