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?

# ブラウザから操作可能なラジコンを作る【9-1】

Last updated at Posted at 2025-03-09

【9-1】iPhoneのセンサーから頭の向きをVRに反映

ストリートビューもどきはとりあえず出来ました。
ここまできたら、よりVRな感じで頭の動きをトラッキングし
ラジコンを走らせながら周りを見渡したい
となるわけです。

しかし、VRゴーグルは持ってないし、これだけのために買うのは何か嫌

というわけで、スマホをはめ込んで使うタイプの低価格VRゴーグルで対応すべく
iPhoneのセンサーを使って、それっぽいことを実現します。


目次

iPhoneの加速度を取る

「DeviceMotionEvent」と言うブラウザAPIを呼ぶだけで取得できるので
難しい所がないと言えば無いです。

iOS13以前とそれ以降で呼び出し方法が異なりますが
現状ではあまり気にする必要はないと思います。

流れとしては

  • DeviceMotionEventの呼び出しをリクエスト
  • ユーザーがポップアップされる許可・キャンセルを選択
  • devicemotionイベントがキャッチ出来るようになるので、センサー情報を取得
  • センサー情報から角度を計算
  • 角度情報をThreeJSのカメラ座標に反映

と言う感じで、VRっぽい動きが再現できます。

DeviceMotionEventのリクエスト

DeviceMotionEventの存在を確認し
「requestPermission」でユーザー選択を待ちます。
許可されると、センサー情報を実際に受け取る「deviceMotionEventWatch」関数を呼んでます。

if (DeviceMotionEvent !== undefined) {
    if('requestPermission' in DeviceMotionEvent) {
        (DeviceMotionEvent as any)
            .requestPermission()
            .then(permissionState => {
                if (permissionState === 'granted') {
                    deviceMotionEventWatch()
                }
            })
            .catch(console.error)
    }
} else {
    alert('DeviceMotionEvent.requestPermission is not found')
}

センサー情報の取得

センサーから受け取れる値は

  • 加速度(重力補正有り)(X軸、Y軸、Z軸)
  • 加速度(重力補正なし)(X軸、Y軸、Z軸)
  • 角度(alpha(Z軸)、beta(X軸)、gamma(Y軸))

となっています。
始めは、加速度からベクトル角計算すればいいと思っていたんですが
角度情報も取れることに気づき、即採用した次第です。

const deviceMotionEventWatch = () => {
    window.addEventListener("devicemotion", function (event) {
        if (!event.accelerationIncludingGravity) {
            alert('event.accelerationIncludingGravity is null');
            return;
        }
        const { x, y, z } = getAccelerationValue(event.accelerationIncludingGravity)
        const { x: x2, y: y2, z: z2 } = getAccelerationValue(event.acceleration)
        const { alpha, beta, gamma } = getRotateValue(event.rotationRate)

        if (showFlag) {
            statusView(String(x), String(y), String(z))
        }

        successCallBack({
            acceleration: {x: x, y: y, z: z},
            gravity: {x: x2, y: y2, z: z2},
            rotate: {alpha: alpha, beta: beta, gamma: gamma}
        })
    })
}

何故センサー値の取得にいちいち関数を挟んでいるか?ですが

  • iPhone以外でも使うかな?と言う後々問題
  • iPhone系だけ加速度が逆
  • null値が返ってくる場合があるので、エラーにならないように置き換える

と言う目的のために1クッション挟んで、改修ポイントにしています。

const getAccelerationValue = (
    value: DeviceMotionEventAcceleration | null
): {
    x: string,
    y: string,
    z: string
} => {
    if (value === null) return { x: 'null', y: 'null', z: 'null' }

    if (isIPhone() || isIPad()) {
        return {
            x: (value.x === null ) ? 'null' :  String(value.x * -1),
            y: (value.y === null ) ? 'null' :  String(value.y * -1),
            z: (value.z === null ) ? 'null' :  String(value.z * -1)
        }
    }

    return {
        x: (value.x === null ) ? 'null' :  String(value.x),
        y: (value.y === null ) ? 'null' :  String(value.y),
        z: (value.z === null ) ? 'null' :  String(value.z)
    }
}

null対応、座標系の反転を行っております。

以上の機能をまとめて「position.service.ts」で保存します。

position.service.ts
import {
    ReturnSuccess, ReturnError
} from '../definisions'

const ViewTarget = {
    x: 'acceleration_x_value',
    y: 'acceleration_y_value',
    z: 'acceleration_z_value'
}

let logTarget = 'position_log'
let LT: HTMLDivElement | null = null

export type PositionType = {
    acceleration: {x: string, y: string, z: string},
    gravity: {x: string, y: string, z: string},
    rotate: {alpha: string, beta: string, gamma: string}
}

let showFlag = false
let successCallBack = (position: PositionType): void => {}

/**
 * 
 */
export const execDeviceMotion = (
    callback: (position: PositionType) => void
) => {
    successCallBack = callback

    if (DeviceMotionEvent !== undefined) {
        if('requestPermission' in DeviceMotionEvent) {
            (DeviceMotionEvent as any)
                .requestPermission()
                .then(permissionState => {
                    if (permissionState === 'granted') {
                        deviceMotionEventWatch()
                    }
                })
                .catch(console.error)
        }
    } else {
        alert('DeviceMotionEvent.requestPermission is not found')
    }
}

const deviceMotionEventWatch = () => {
    window.addEventListener("devicemotion", function (event) {
        if (!event.accelerationIncludingGravity) {
            alert('event.accelerationIncludingGravity is null');
            return;
        }
        const { x, y, z } = getAccelerationValue(event.accelerationIncludingGravity)
        const { x: x2, y: y2, z: z2 } = getAccelerationValue(event.acceleration)
        const { alpha, beta, gamma } = getRotateValue(event.rotationRate)

        if (showFlag) {
            statusView(String(x), String(y), String(z))
        }

        successCallBack({
            acceleration: {x: x, y: y, z: z},
            gravity: {x: x2, y: y2, z: z2},
            rotate: {alpha: alpha, beta: beta, gamma: gamma}
        })
    })
}

const statusView = (
    x: string,
    y: string,
    z: string
) => {
    const xView = document.getElementById(ViewTarget.x)
    const yView = document.getElementById(ViewTarget.y)
    const zView = document.getElementById(ViewTarget.z)

    if (xView) xView.innerHTML = x
    if (yView) yView.innerHTML = y
    if (zView) zView.innerHTML = z
}

const getAccelerationValue = (
    value: DeviceMotionEventAcceleration | null
): {
    x: string,
    y: string,
    z: string
} => {
    if (value === null) return { x: 'null', y: 'null', z: 'null' }

    if (isIPhone() || isIPad()) {
        return {
            x: (value.x === null ) ? 'null' :  String(value.x * -1),
            y: (value.y === null ) ? 'null' :  String(value.y * -1),
            z: (value.z === null ) ? 'null' :  String(value.z * -1)
        }
    }

    return {
        x: (value.x === null ) ? 'null' :  String(value.x),
        y: (value.y === null ) ? 'null' :  String(value.y),
        z: (value.z === null ) ? 'null' :  String(value.z)
    }
}

const getRotateValue = (
    value: DeviceMotionEventRotationRate  | null
): {
    alpha: string,
    beta: string,
    gamma: string
} => {
    if (value === null) return { alpha: 'null', beta: 'null', gamma: 'null' }

    return {
        alpha: (value.alpha === null ) ? 'null' :  String(value.alpha),
        beta: (value.beta === null ) ? 'null' :  String(value.beta),
        gamma: (value.gamma === null ) ? 'null' :  String(value.gamma)
    }
}

const isIPhone = () => {
    return /iPhone/.test(navigator.userAgent)
}

const isIPad = () => {
    return /Macintosh/.test(navigator.userAgent)
}

センサー情報の取得サンプル

ブラウザ側から実際に呼び出す関数を作り、試験で呼び出してみます。


import { execDeviceMotion, PositionType } from '@/src/_lib/position/acceleration.service'

export const watchPosition = async (): Promise<void> => {
    execDeviceMotion(positionDevelop)
}

const positionDevelop = (position: PositionType): void => {
    
    const _x1 = document.getElementById('x_1')
    const _y1 = document.getElementById('y_1')
    const _z1 = document.getElementById('z_1')

    const _x2 = document.getElementById('gx_2')
    const _y2 = document.getElementById('gy_2')
    const _z2 = document.getElementById('gz_2')

    // log!.innerHTML = JSON.stringify(position)

    _x1!.innerHTML = String(Math.floor(Number(position.acceleration.x) * 1000000) / 1000000)
    _y1!.innerHTML = String(Math.floor(Number(position.acceleration.y) * 1000000) / 1000000)
    _z1!.innerHTML = String(Math.floor(Number(position.acceleration.z) * 1000000) / 1000000)

    _x2!.innerHTML = String(Math.floor(Number(position.gravity.x) * 1000000) / 1000000)
    _y2!.innerHTML = String(Math.floor(Number(position.gravity.y) * 1000000) / 1000000)
    _z2!.innerHTML = String(Math.floor(Number(position.gravity.z) * 1000000) / 1000000)
}

センサー情報をブラウザ上の適当なDOMに割り振ると
下記のように、センサーの値が取得できます。


ThreeJSに適応

センサー情報の取得が出来ました。
この情報をThreeJSのカメラに渡すわけですが
第8回の「panorama.service.ts」内に、カメラのアングルを変える関数が
既に組み込んであります。

センサーから送られてくる情報が、角度、では無く
角方向の加速度、なので、そのままカメラの座標情報に組み込むと
永遠に動き続けます。

ですので

動かす際は、加速度を適度に減衰してから積分することで
頭を動かした時間分だけの移動量を計算し

動かしていない時は、減衰させることで動きを止めるようにします。

積算ペースを上げると、動き始めに機敏に反応し
減衰ペースを上げると、その場でピタッと止まります。

ここは調整ポインになります。

// カメラの角度を変更する関数
export const setCameraAngle = (
    lookAtX: number,
    lookAtY: number,
    lookAtZ: number
) => {
    if (!camera) return

    // 加速度を積分して速度を計算
    velocity.x += lookAtX * 0.01; // 0.01はサンプリング間隔の例
    velocity.y += lookAtY * 0.01;
    velocity.z += lookAtZ * 0.01;

    // 速度を減衰させる
    velocity.x *= dampingFactor;
    velocity.y *= dampingFactor;
    velocity.z *= dampingFactor;
    

    // 速度を積分して位置を計算し、カメラの回転を更新
    cameraRotation.x += MathUtils.degToRad(velocity.x);
    cameraRotation.y -= MathUtils.degToRad(velocity.y);
    cameraRotation.z += MathUtils.degToRad(velocity.z);

    camera.rotation.x = cameraRotation.x;
    camera.rotation.y = cameraRotation.y;
    camera.rotation.z = cameraRotation.z;
}

この関数を、DOMと紐づけされた関数に組み込みます。

角度情報をThreeJSに渡す

ThreeJSのパノラマ表示で作成した関数「setCameraAngle」を読み込み
座標系のブラウザ表示を、関数への引き渡しを行う中間関数を作ります。


import { setCameraAngle } from './panorama.service'
import { execDeviceMotion, PositionType } from '@/src/_lib/position/acceleration.service'

export const watchPosition = async (): Promise<void> => {
    execDeviceMotion(positionDevelop)
}

const positionDevelop = (position: PositionType): void => {
    
    const _x1 = document.getElementById('x_1')
    const _y1 = document.getElementById('y_1')
    const _z1 = document.getElementById('z_1')

    const _x2 = document.getElementById('gx_2')
    const _y2 = document.getElementById('gy_2')
    const _z2 = document.getElementById('gz_2')

    // log!.innerHTML = JSON.stringify(position)

    _x1!.innerHTML = String(Math.floor(Number(position.acceleration.x) * 1000000) / 1000000)
    _y1!.innerHTML = String(Math.floor(Number(position.acceleration.y) * 1000000) / 1000000)
    _z1!.innerHTML = String(Math.floor(Number(position.acceleration.z) * 1000000) / 1000000)

    _x2!.innerHTML = String(Math.floor(Number(position.gravity.x) * 1000000) / 1000000)
    _y2!.innerHTML = String(Math.floor(Number(position.gravity.y) * 1000000) / 1000000)
    _z2!.innerHTML = String(Math.floor(Number(position.gravity.z) * 1000000) / 1000000)

    publishAngle(
        (position.rotate.beta === 'null') ? 0 : Number(position.rotate.beta),
        (position.rotate.alpha === 'null') ? 0 : Number(position.rotate.alpha),
        (position.rotate.gamma === 'null') ? 0 : Number(position.rotate.gamma),
    )

}

const publishAngle = (x: number, y: number, z: number): void => {
    setCameraAngle(x, y, z,)
}

試験

実際に動かしてみますと、iPhoneの動きに連動して
アングルが切り替わることが確認できると思います。

スマホVRゴーグルへの適応が残っていますが
長くなりましたので、次回に続きます。




UZAYA

Uzayaでは、多分仕事を求めています。
何かの役に立ちそうでしたら、是非お知らせを。

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?