https://kakomarevrm.herokuapp.com/
このようなブラウザアプリを公開しました。ブラウザ内で VRM ファイルを読み込み、それらが自分(カメラ)を見たり微笑んだりしてくれるというものです。本アプリのフロント部分(といってもindex.html
1個のみですが)はリポジトリを公開しています。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.i
をvrm_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.js
のVector3
オブジェクトには、標準化を内積を計算する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 より大きい時は、視線と顔への方向の角度が一致しているので、これで「視線が顔を向いた時だけ笑顔になる」機能が実装できます。