LoginSignup
5
5

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-01-22

 https://kakomarevrm.herokuapp.com/

 このようなブラウザアプリを公開しました。ブラウザ内で VRM ファイルを読み込み、それらが自分(カメラ)を見たり微笑んだりしてくれるというものです。本アプリのフロント部分(といってもindex.html1個のみですが)はリポジトリを公開しています。VRM の読み込み方などについては前回の記事を参照。

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

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

//位置だけ先に定義
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 がシーンに描き出されます。ローカルファイルであればパスを指定すれば、今回はブラウザにファイル自体を読み込ませます。そのため、データ自体(バイナリファイル)を読み込んでくれる仕組みが必要になります。

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

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

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で関数に引数をわたす が参考になりました。この場を借りてお礼を申し上げます。

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

まばたきをさせる

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

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にプリセットで登録されているブレイドシェイプのキーは以下の通りになります。

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

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

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

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という値が設定されていて、これを参照するとエラーになります。存在が謎ですが、多分きっと理由とか経緯があるのでしょう。やむなくアドホックな修正をしています。

 そもそも、

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

 等をすればここら辺の排他制御は解決しそうですが、さすがにイシューでも話し合われているみたいですね。VRM1.0 では対応するようです。

自分(カメラ)の方を見てくれる

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

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

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

//目の高さを記憶
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を噛ませば仰角が出てきます。

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.jsVector3オブジェクトには、標準化を内積を計算するAPIが搭載されています。標準化したベクトル同士の内積を取るとコサインが出てくるので、それに$cos^{-1}$をかけることにより角度を算出します。

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 より大きい時は、視線と顔への方向の角度が一致しているので、これで「視線が顔を向いた時だけ笑顔になる」機能が実装できます。

5
5
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
5
5