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




## 【９-１】iPhoneのセンサーから頭の向きをVRに反映

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

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

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


<br></br>
## 目次
* [【１】Raspberry pi の、GPIOをTypescriptから操作](https://qiita.com/Yasukosan/items/1bf9480c10de7d69c2a4)
* [【２】DCモーターをPWMで速度制御](https://qiita.com/Yasukosan/items/291c76c10be135fd7595)
* [【３】サーボモーターを制御](https://qiita.com/Yasukosan/items/e81c9c9d0159b32e5893)
* [【４】DualSenseをブラウザに接続](https://qiita.com/Yasukosan/items/bf3a085145db84a48823)
* [【５】DualSenseの情報をRaspberryPiに飛ばす](https://qiita.com/Yasukosan/items/400883be94751966221a)
* [【６】DualSenseのチャタリング？問題対応](https://qiita.com/Yasukosan/items/7e52609699b4b71196ca)
* [【７-１】RaspberryPiから低遅延で映像を飛ばす](https://qiita.com/Yasukosan/items/98426f1ba4dea4c9ec5f)
* [【７-２】RaspberryPiから低遅延で映像を飛ばす](https://qiita.com/Yasukosan/items/2246c8a32602ca77ace9)
* [【８】ThreeJSでVRもどきを作成](https://qiita.com/Yasukosan/items/e15e8fd002cf49e3d04d)
* [【９-１】iPhoneの加速度から頭の向きをVRに反映](https://qiita.com/Yasukosan/items/9b4333770baea4110361)
* [【９-２】スマホVRゴーグル向けデザインに変える](https://qiita.com/Yasukosan/items/7f1bb7a9d95cb76f8117)
* [【１０】ラジコン本体の製作](https://qiita.com/Yasukosan/items/0d37c1df5b5d6c02257b)
* 【１１】パワーアップとバッテリー問題の解決



## iPhoneの加速度を取る

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

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

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

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


### DeviceMotionEventのリクエスト

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

```typescript
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軸））

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

```typescript
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クッション挟んで、改修ポイントにしています。

```typescript
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」で保存します。

<details><summary>position.service.ts</summary>

```javascript
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)
}
```
</details>

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

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

```typescript

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に割り振ると
下記のように、センサーの値が取得できます。

<iframe width="560" height="315" src="https://www.youtube.com/embed/ENhsiGofDgw?si=vWl_a-dN4EeS1g9X" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>


<br></br>
## ThreeJSに適応

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

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

ですので

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

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

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

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

```typescript
// カメラの角度を変更する関数
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」を読み込み
座標系のブラウザ表示を、関数への引き渡しを行う中間関数を作ります。


```typescript

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の動きに連動して
アングルが切り替わることが確認できると思います。

<iframe width="560" height="315" src="https://www.youtube.com/embed/AG_9fB5hhMs?si=Y-GNUY2qBzrU_SnN" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

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


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