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?

Mapbox GL JSとThree.jsを同一Canvas上で動かしてARマップを作った実装記録

0
Posted at

CityCanvasというソーシャルARプラットフォームを個人開発しています。「GPS座標にARコンテンツを置いて、カメラで覗くと見える」という体験を作るにあたって、最大の技術的な山場が地図レイヤーと3D空間の統合でした。

この記事では、Mapbox GL JSとThree.jsを同じCanvas上で動かすまでの試行錯誤を記録します。

やろうとしたこと

  • Mapboxの地図を背景として表示
  • 地図上のGPS座標に対応する位置に、Three.jsで描画した3Dオブジェクトを重ねる
  • カメラの移動・ズームに合わせて3Dオブジェクトの位置も追従する

一見シンプルに見えますが、「座標系が全然違う2つのライブラリを同期させる」という問題があります。

最初のアプローチ:HTML要素の重ね合わせ

最初は単純に考えて、MapboxのcanvasとThree.jsのcanvasをposition: absoluteで重ね合わせる方針で実装しました。

// 最初のアプローチ(NG版)
const mapContainer = document.getElementById('map');
const threeCanvas = document.getElementById('three-canvas');

// CSSで重ね合わせ
threeCanvas.style.position = 'absolute';
threeCanvas.style.top = '0';
threeCanvas.style.left = '0';
threeCanvas.style.pointerEvents = 'none';

const renderer = new THREE.WebGLRenderer({
  canvas: threeCanvas,
  alpha: true, // 透明背景
});

// Mapboxの地図が動いたらThree.jsの座標も更新
map.on('move', updateThreeObjects);

function updateThreeObjects() {
  arObjects.forEach(obj => {
    // GPS → スクリーン座標変換
    const point = map.project([obj.lng, obj.lat]);
    obj.mesh.position.x = (point.x / window.innerWidth) * 2 - 1;
    obj.mesh.position.y = -(point.y / window.innerHeight) * 2 + 1;
  });
}

問題が3つ発生しました。

  1. ズームレベルが変わると奥行き感がまったく合わない(地図は2Dスケールで拡大されるが、Three.jsのperspective cameraは変わらない)
  2. map.on('move')requestAnimationFrameのタイミングがずれて、地図と3Dオブジェクトの位置が一瞬ブレる
  3. pointer-events: noneにしているので3Dオブジェクトへのタップが拾えない

解決策:Mapboxのカスタムレイヤーとして Three.js を埋め込む

調べると、MapboxにはaddLayerにカスタムレイヤーとしてCustomLayerInterfaceを渡せる仕組みがあります。これを使うとMapboxの描画パイプラインの中にThree.jsを統合できるので、Canvas 1枚で同期した描画ができます。

// マップの初期化
const map = new mapboxgl.Map({
  container: 'map',
  style: 'mapbox://styles/mapbox/dark-v11',
  center: [139.7671, 35.6812], // 東京
  zoom: 15,
  pitch: 45,
  antialias: true, // Three.jsと共有するためtrueが必要
});

// カスタムレイヤーの定義
const arLayer = {
  id: 'ar-objects-layer',
  type: 'custom',
  renderingMode: '3d',

  onAdd(map, gl) {
    // MapboxのWebGLコンテキストをThree.jsに渡す
    this.renderer = new THREE.WebGLRenderer({
      canvas: map.getCanvas(),
      context: gl,
      antialias: true,
    });
    this.renderer.autoClear = false; // Mapboxの描画を消さないように

    this.scene = new THREE.Scene();
    this.camera = new THREE.Camera();
    this.map = map;

    // ライティング
    const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
    this.scene.add(ambientLight);
    const directionalLight = new THREE.DirectionalLight(0x00ffcc, 0.8);
    directionalLight.position.set(0, -70, 100).normalize();
    this.scene.add(directionalLight);
  },

  render(gl, matrix) {
    // Mapboxから渡されるmatrixはMercator座標系のプロジェクション行列
    const rotationX = new THREE.Matrix4().makeRotationAxis(
      new THREE.Vector3(1, 0, 0),
      Math.PI / 2
    );

    const m = new THREE.Matrix4().fromArray(matrix);
    const l = new THREE.Matrix4()
      .makeTranslation(
        this.modelTransform.translateX,
        this.modelTransform.translateY,
        this.modelTransform.translateZ
      )
      .scale(
        new THREE.Vector3(
          this.modelTransform.scale,
          -this.modelTransform.scale,
          this.modelTransform.scale
        )
      )
      .multiply(rotationX);

    this.camera.projectionMatrix = m.multiply(l);
    this.renderer.resetState();
    this.renderer.render(this.scene, this.camera);
    this.map.triggerRepaint(); // 毎フレーム再描画を要求
  },
};

ここでの肝は this.renderer.autoClear = falsethis.renderer.resetState() の2行です。Mapboxが先に描画したフレームバッファをThree.jsが上書き消去しないようautoClearを切り、Three.jsがWebGLの内部ステートを変更した後でMapboxが壊れないようresetState()でリセットしています。

GPS座標 → Mercator座標変換

カスタムレイヤーに渡すオブジェクトの位置は、MercatorProjection座標で指定する必要があります。MapboxにはMercatorCoordinateというユーティリティがあります。

function gpsToMercator(lng, lat, altitude = 0) {
  return mapboxgl.MercatorCoordinate.fromLngLat(
    { lng, lat },
    altitude
  );
}

function addArObject(lng, lat, altitude, glbUrl) {
  const mercatorCoord = gpsToMercator(lng, lat, altitude);

  // MercatorCoordinateはメートルスケールではないので変換が必要
  // meterInMercatorCoordinateUnits()で1メートルあたりの単位を取得
  const scale = mercatorCoord.meterInMercatorCoordinateUnits();

  arLayer.modelTransform = {
    translateX: mercatorCoord.x,
    translateY: mercatorCoord.y,
    translateZ: mercatorCoord.z,
    scale: scale,
  };

  // GLTFローダーでモデルを読み込む
  const loader = new GLTFLoader();
  loader.load(glbUrl, (gltf) => {
    arLayer.scene.add(gltf.scene);
    map.triggerRepaint();
  });
}

meterInMercatorCoordinateUnits()で取得できるスケール値が重要で、これがないと3Dモデルがミリサイズだったり建物より巨大になったりします(最初ハマりました)。

デバイスカメラとの合成(AR本来の機能)

地図モードとは別に、カメラビューモードではgetUserMediaでカメラ映像を取得してThree.jsのバックグラウンドに描画します。

async function startCameraAR() {
  const stream = await navigator.mediaDevices.getUserMedia({
    video: {
      facingMode: 'environment', // リアカメラ
      width: { ideal: 1920 },
      height: { ideal: 1080 },
    },
  });

  const video = document.createElement('video');
  video.srcObject = stream;
  video.play();

  // カメラ映像をThree.jsのテクスチャとして背景に設定
  const videoTexture = new THREE.VideoTexture(video);
  scene.background = videoTexture;

  // デバイスの姿勢でカメラを制御
  if (window.DeviceOrientationEvent) {
    window.addEventListener('deviceorientation', onDeviceOrientation);
  }
}

function onDeviceOrientation(event) {
  const alpha = THREE.MathUtils.degToRad(event.alpha || 0); // Z軸(方位)
  const beta = THREE.MathUtils.degToRad(event.beta || 0);   // X軸(前後傾き)
  const gamma = THREE.MathUtils.degToRad(event.gamma || 0); // Y軸(左右傾き)

  // Euler角からクォータニオンへ(ジンバルロック回避)
  const euler = new THREE.Euler(beta, alpha, -gamma, 'YXZ');
  camera.quaternion.setFromEuler(euler);
}

iOSの場合、iOS 13以降はDeviceOrientationEvent.requestPermission()を明示的にユーザーインタラクション内で呼ぶ必要があります。ここでも一度詰まりました。

// iOS向けの権限要求(ボタンタップのハンドラ内で呼ぶ必要がある)
async function requestIOSMotionPermission() {
  if (
    typeof DeviceOrientationEvent !== 'undefined' &&
    typeof DeviceOrientationEvent.requestPermission === 'function'
  ) {
    const permission = await DeviceOrientationEvent.requestPermission();
    if (permission !== 'granted') {
      throw new Error('モーションセンサーの権限が拒否されました');
    }
  }
}

Capacitorでネイティブアプリ化する際の注意点

PWAとして動く状態になってからCapacitorでネイティブ化しましたが、getUserMediaはCapacitorのWebView上でも動作はするものの、Androidではカメラ権限のマニフェスト追加が必要です。

<!-- android/app/src/main/AndroidManifest.xml に追加 -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />

またCapacitor側のJavaScriptブリッジから権限リクエストを出す場合は@capacitor/cameraプラグインを使うのが安全です。WebView内で直接getUserMediaを呼ぶと「安全でないコンテキスト」エラーになるケースがあり、capacitor.config.jsonserver.allowNavigation設定が必要になることがあります。

最終的な構成

CityCanvas アーキテクチャ
├── 地図モード
│   ├── Mapbox GL JS(タイル地図・ルーティング)
│   └── Three.js(CustomLayerInterfaceで統合)
│       └── Mercator座標系で3Dオブジェクト配置
├── ARカメラモード
│   ├── getUserMedia(カメラ映像)
│   ├── Three.js(VideoTextureで背景、3D重ね合わせ)
│   └── DeviceOrientation API(姿勢追跡)
├── バックエンド
│   ├── Supabase(DB・Auth・Realtime)
│   └── PostGIS(半径検索: ST_DWithin)
└── 3D生成
    └── Meshy AI API(テキスト→GLBモデル生成)

ハマりポイントのまとめ

  • autoClear: falseresetState() はMapbox+Three.js統合の必須セット
  • meterInMercatorCoordinateUnits() を忘れるとスケールが狂う
  • iOS DeviceOrientation はユーザーインタラクション内でのみ権限要求できる
  • Capacitorのカメラ はWebViewのgetUserMediaではなくネイティブプラグインを経由する方が安定する

座標系の違いに起因するバグは原因特定に時間がかかるので、早い段階でTHREE.AxesHelperや座標値のconsole出力を入れてデバッグするのがおすすめです。


参考

CityCanvasのアプリ全体の設計とユースケースについては、以下のブログ記事で詳しく紹介しています。
https://mcw999.github.io/mcw999-hub/blog/city-canvas-guide/?utm_source=qiita&utm_medium=referral&utm_campaign=city-canvas

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?