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が渡してくるalpha・beta・gammaは、デバイスのオイラー角(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()で使