Three.js で VRM に色々な挙動をさせようとしたら割と数学実装になった

　https://kakomarevrm.herokuapp.com/

https://twitter.com/hibit_at/status/1484474508769587201

　このようなブラウザアプリを公開しました。ブラウザ内で VRM ファイルを読み込み、それらが自分（カメラ）を見たり微笑んだりしてくれるというものです。本アプリのフロント部分（といっても`index.html`1個のみですが）は[リポジトリ](https://github.com/hibit-at/kakomarevrm)を公開しています。VRM の読み込み方などについては[前回の記事](https://qiita.com/hibit/items/0d119d171bcbe3b2c1c5)を参照。

# ローカルファイルの読み込み

　ローカルファイルの読み込みに少し苦労したので備忘録。VRM ファイル自体の読み込みは以下のような関数で行います。

```javascript
//位置だけ先に定義
const radius = 0.5
for (let i = 0; i < 4; i++) {
    positions[i] = [
        radius * Math.sin(i * 2 * Math.PI / 4),
        0,
        radius * Math.cos(i * 2 * Math.PI / 4)
    ]
}

//loader
function vrm_loading(vrm_path, i) {
    const loader = new THREE.GLTFLoader();
    loader.load(
        vrm_path,
        (gltf) => {
            THREE.VRM.from(gltf).then((vrm) => {
                vrm_datas[i] = vrm
                vrm_objects[i] = vrm.scene
                normalPose(vrm)
                var pos = positions[i]
                vrm_objects[i].position.set(pos[0], 0, pos[2]);
                vrm_objects[i].rotateY(2 * i * Math.PI / 4)
                scene.add(vrm_objects[i])
                //目の高さを記憶
                var eye = vrm.humanoid.getBoneNode(THREE.VRMSchema.HumanoidBoneName.LeftEye)
                world_eye = new THREE.Vector3()
                eye.getWorldPosition(world_eye)
                world_eye.x = 0
                original_heights[i] = world_eye
                //足の長さを記憶
                var leg = vrm.humanoid.getBoneNode(THREE.VRMSchema.HumanoidBoneName.LeftUpperLeg)
                world_leg = new THREE.Vector3()
                leg.getWorldPosition(world_leg)
                world_leg.x = 0
                original_legs[i] = world_leg
            });
        },
        (progress) => console.log('Loading model...', 100.0 * (progress.loaded / progress.total), '%'),
        (error) => console.error(error)
    )
}
```

　ようは、この関数に`vrm_path`（パス）と`i`（何体目か）を入れれば、VRM がシーンに描き出されます。ローカルファイルであればパスを指定すれば、今回はブラウザにファイル自体を読み込ませます。そのため、データ自体（バイナリファイル）を読み込んでくれる仕組みが必要になります。

　結論的に言うと、**これはパス用の引数にデータ自体をぶちこむ**ことで成功しました。エラーになると思ってダメ元で試したら上手くいったので、なぜこのようなことになったのか現在の自分ではわかりません。

　以下がボタンを押してファイルを読み込む部分。

```javascript
for (let i = 0; i < 4; i++) {
    fileinputs[i] = document.getElementById('avatar' + (i + 1))
    fileinputs[i].addEventListener('click', (e) => {
        e.target.value = '';
    })
    fileinputs[i].addEventListener('change', {
        i: i,
        handleEvent: handleFile,
    })
}

function handleFile(e) {
    const itr_num = this.i;
    const file = e.currentTarget.files[0];
    var reader = new FileReader();
    reader.readAsDataURL(file);
    scene.remove(vrm_objects[this.i]);
    reader.onload = function() {
        vrm_loading(reader.result, itr_num);
    }
}
```

　また、`addEventListner`に引数を含ませる方法については、[【JavaScript】addEventListenerで関数に引数をわたす](https://note.com/yamanoborer/n/n2e4cc40328b7) が参考になりました。この場を借りてお礼を申し上げます。

　また、`this.i`を`vrm_loading`として渡すとなぜか `undefined`になってエラーになったので、上ではやむなく`itr_num`という代理変数を用意しています。これでエラーがなくなりましたが、なぜこのようになるのかはわかりません（二回目）。

# まばたきをさせる

　目を見開きっぱなしだと気持ち悪いので、まばたきをさせます。こういう時には乱数を使いましょう。`Math.random()`を用います。0.1 秒間隔で判定を行い、素の表情なら10%の確率ならまばたきに、まばたきなら素の表情に戻します。これでランダムな間隔（期待値 1 秒）で、0.1 秒のまばたきを行うことになります。

```javascript
setInterval(blink, 100)

function blink() {
    for (let i = 0; i < 4; i++) {
        if (vrm_objects[i]) {
            var vrm = vrm_datas[i]
            var object = vrm_objects[i]
            var blinkValue = vrm.blendShapeProxy.getValue(THREE.VRMSchema.BlendShapePresetName.Blink)
            var joyValue = vrm.blendShapeProxy.getValue(THREE.VRMSchema.BlendShapePresetName.Joy)
            if (blinkValue === 0) {
                var rand = Math.random()
                if (rand > .9 && isNoExpression(vrm)) {
                    vrm.blendShapeProxy.setValue('blink', 1)
                }
            } else {
                vrm.blendShapeProxy.setValue(THREE.VRMSchema.BlendShapePresetName.Blink, 0)
            }
            vrm.blendShapeProxy.update()
        }
    }
}

```

　笑顔の状態の時にまばたきシェイプキーを合成してしまうと、2つの表情が合算されて表情が壊れてしまうので、笑顔の時はまばたきをしないように判定しています。実用上のことを考えると、表情の破綻を防ぐために、**今が素の表情か（すべてのブレンドシェイプが 0 か**というのは結構重要な気がするので、1 行で判定できるAPIがあった方がいい気がするのですが（あったらすいません）。

　VRMにプリセットで登録されているブレイドシェイプのキーは以下の通りになります。

```javascript
['A', 'Angry', 'Blink', 'BlinkL', 'BlinkR', 'E', 'Fun', 'I', 'Joy', 'Lookdown', 'Lookleft', 'Lookright', 'Lookup', 'Neutral', 'O', 'Sorrow', 'U', 'Unknown']
```

　これらを全て手打ちするのは大変なので、これらがまとまった辞書配列`THREE.VRMSchema.BlendShapePresetName`を利用します。

　今回は、便宜的に次のような関数`isNoExpression`を用意しています。

```javascript
function isNoExpression(vrm){
    const keys = Object.values(THREE.VRMSchema.BlendShapePresetName)
    keyvalues = keys.map(key => {
        if(key === 'unknown'){
            return 0;
        }else{
            return vrm.blendShapeProxy.getValue(key)
        }
    })
    return sum(keyvalues) === 0
}
```

　現状、`THREE.VRMSchema.BlendShapePresetName`の中に`unknown`という値が設定されていて、これを参照するとエラーになります。存在が謎ですが、多分きっと理由とか経緯があるのでしょう。やむなくアドホックな修正をしています。

　そもそも、

- ブレンドシェイプの合算を、部位ごとにマスクを指定する
- ブレンドシェイプを上書きする命令と、乗算する命令とに分ける

　等をすればここら辺の排他制御は解決しそうですが、さすがに[イシュー](https://github.com/vrm-c/vrm-specification/issues/4)でも話し合われているみたいですね。VRM1.0 では対応するようです。

# 自分（カメラ）の方を見てくれる

　これはそのものずばり`lookAt`という API が用意されています。これをカメラの子オブジェクトに貼り付ければうまくいく……と思ったら上手くいかず。まあそんな重い実装でもないし、どうせ後でもっと複雑な機能が必要になるので、数学実装していきます。

　カメラの高さと VRM の頭の高さの差を取れば簡単な三角関数（$tan^{-1}$）によって仰角は出てきます。カメラの高さはすぐに取り出せるので、問題はVRMの頭の高さです。単純に考えると、`head`のボーンの位置がそれに当たりそうですが、実際に`vrm.humanoid.getBoneNode(THREE.VRMSchema.HumanoidBoneName.Head).position.y`を出すと、かなり小さい値が出てきます。

　普段から3Dモデルをいじっているとピンときますが、これは親ボーンに対する相対位置です。ワールド座標がほしい場合はそれ用にメソッド`getWorldPosition`が用意されているので、実際にはそれを事前に計算しています。（その都度ワールド座標を取得する場合、腰をかがめたりする関係で、値が動的に変わってややこしくなるため）

```javascript
//目の高さを記憶
var eye = vrm.humanoid.getBoneNode(THREE.VRMSchema.HumanoidBoneName.LeftEye)
var world_eye = new THREE.Vector3()
eye.getWorldPosition(world_eye)
world_eye.x = 0
original_heights[i] = world_eye
```

　この部分ですね。

　あとは高さの差を取って、それに`atan2`を噛ませば仰角が出てきます。

```javascript
function angleCheck() {
    for (let i = 0; i < 4; i++) {
        if (vrm_objects[i]) {
            var vrm = vrm_datas[i]
            var object = vrm_objects[i]
            var head = vrm.humanoid.getBoneNode(THREE.VRMSchema.HumanoidBoneName.Head)
            const diff = camera.position.y - original_heights[i].y
            if (diff >= 0) {
                head.rotation.x = Math.atan2(diff, radius)
            }else if(diff >= -.5){
                var chest = vrm.humanoid.getBoneNode(THREE.VRMSchema.HumanoidBoneName.Chest)
                chest.rotation.x = Math.atan2(diff, radius)
            }else{
                var bendAngle = Math.atan2(-.25 + diff/2,radius)
                var chest = vrm.humanoid.getBoneNode(THREE.VRMSchema.HumanoidBoneName.Chest)
                var spine = vrm.humanoid.getBoneNode(THREE.VRMSchema.HumanoidBoneName.Spine)
                chest.rotation.x = bendAngle
                var over_diff = diff + .5
                var hips = vrm.humanoid.getBoneNode(THREE.VRMSchema.HumanoidBoneName.Hips)
                var armo = hips.parent
                armo.position.y = over_diff
                var LUL = vrm.humanoid.getBoneNode(THREE.VRMSchema.HumanoidBoneName.LeftUpperLeg)
                var LLL = vrm.humanoid.getBoneNode(THREE.VRMSchema.HumanoidBoneName.LeftLowerLeg)
                var LF = vrm.humanoid.getBoneNode(THREE.VRMSchema.HumanoidBoneName.LeftFoot)
                var RUL = vrm.humanoid.getBoneNode(THREE.VRMSchema.HumanoidBoneName.RightUpperLeg)
                var RLL = vrm.humanoid.getBoneNode(THREE.VRMSchema.HumanoidBoneName.RightLowerLeg)
                var RF = vrm.humanoid.getBoneNode(THREE.VRMSchema.HumanoidBoneName.RightFoot)
                var leg_length = original_legs[i]
                var legBendAngle = Math.acos((leg_length.y + over_diff) / leg_length.y)
                LUL.rotation.x = legBendAngle * 2
                LUL.rotation.z = -legBendAngle / 20
                LLL.rotation.x = -legBendAngle * 3
                LF.rotation.x = legBendAngle / 4
                RUL.rotation.x = legBendAngle * 2
                RUL.rotation.z = legBendAngle / 20
                RLL.rotation.x = -legBendAngle * 3
                RF.rotation.x = legBendAngle / 4
            }
        }
    }
}
```

　`else`以下がごちゃごちゃしていますが、これはポーズの味付けです。人体の構造として、見上げる場合は首を傾けて、見下ろす場合は体ごと屈むのが自然なので、そのような制御にしています。

　これで「自分を見てくれる」機構は完成しました。

# 目が合うと笑う

　次は、目が合うと笑ってくれる機構です。視線の先に顔がある判定ですが、これは

- カメラと顔を結んだベクトル
- 視線のベクトル

　が似ているかを判定すればよくて、つまりこれは両者の角度が近いかどうかです。このような時には **†内積†** を用います。幸い、`THRee.js`の`Vector3`オブジェクトには、標準化を内積を計算するAPIが搭載されています。標準化したベクトル同士の内積を取るとコサインが出てくるので、それに$cos^{-1}$をかけることにより角度を算出します。

```javascript
function joyCheck() {
    for (let i = 0; i < 4; i++) {
        if (vrm_objects[i]) {
            var vrm = vrm_datas[i]
            var object = vrm_objects[i]
            var height = original_heights[i].y
            var head_vec = new THREE.Vector3(
                object.position.x,
                camera.position.y - height,
                object.position.z,
            )
            head_vec.normalize()
            camera_vec = new THREE.Vector3(
                Math.sin(camera.rotation.y) * Math.cos(camera.rotation.x),
                Math.sin(camera.rotation.x),
                Math.cos(camera.rotation.y) * Math.cos(camera.rotation.x),
            )
            const inner = camera_vec.dot(head_vec)
            if (Math.abs(inner) > 0.9) {
                vrm.blendShapeProxy.setValue(THREE.VRMSchema.BlendShapePresetName.Joy, 1.0)
                vrm.blendShapeProxy.update()
            } else {
                vrm.blendShapeProxy.setValue(THREE.VRMSchema.BlendShapePresetName.Joy, 0.0)
                vrm.blendShapeProxy.update()
            }
        }
    }
}
```

　内積が 0.9 より大きい時は、視線と顔への方向の角度が一致しているので、これで「視線が顔を向いた時だけ笑顔になる」機能が実装できます。