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つ発生しました。
- ズームレベルが変わると奥行き感がまったく合わない(地図は2Dスケールで拡大されるが、Three.jsの
perspective cameraは変わらない) -
map.on('move')とrequestAnimationFrameのタイミングがずれて、地図と3Dオブジェクトの位置が一瞬ブレる -
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 = false と this.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.jsonにserver.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: falseとresetState()は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