SkyWay ConfでVRM対応のアバターチャットを作る
https://gyazo.com/001f7bda2a521e80add82dcfb6c9ba95
基本情報
概要
SkyWay ConferenceというWeb会議アプリがOSSとして公開されました。
このSkyWayConfを拡張し、VRMを読み込んでアバターWeb会議を出来るようにしてみました。
機能とそれを実現するライブラリは以下の通りです。
VRMの読み込み
Webカメラからの表情認識
WebRTCによる映像、音声通話
pixiv/three-vrmを使ってVRMを扱う
three.jsの基本的な使い方やVRMの読み込みについては割愛します。
pixiv/three-vrmでVRMを読み込んでシーンに追加する方法は以下を参考にしてください。
VRMのボーンを制御する
読み込んだVRMのボーンを回転させる方法についてです。
その後rotationの値を変更します。
例) 頭を回転させる
code:ts
const head = vrm.humanoid.getBoneNode(VRMSchema.HumanoidBoneName.Head);
head.rotation.y = Math.PI /4;
VRMの表情を制御する
VRMの表情を変更する方法についてです。
code:ts
setValue(name:string,weight:number):void
name
対象のBlendShape名を指定します。
weight
変化量
例) 表情「あ」を1.0にする
code:ts
vrm.blendShapeProxy.setValue("a", 1.0);
カメラ位置をモデルの身長にあわせる
VRMの身長はモデルごとにバラバラなので読み込んだモデルごとに大きさを調整するか、カメラを移動させる必要があります。
今回はheadボーンのワールド座標を身長としてその高さにカメラを移動しています。
code:ts
const head = vrm.humanoid.getBoneNode(VRMSchema.HumanoidBoneName.Head)
const headPos = head.getWorldPosition();
camera.position.y = headPos.y;
カメラ目線
code:ts
vrm.lookAt.target = camera;
jeelizWebojiで表情認識
jeelizの設定
jeelizWeboji/dist以下のファイルを使用します。
code:ts
const faceTransferAPI = require("./jeeliz/jeelizFaceTransferES6.js");
const neuralNetworkModel = require('./jeeliz/jeelizFaceTransferNNC.json');
canvasが必要になるので生成しbodyに追加し、また初期設定時に必要になるのでidを設定します。
code:ts
const jeelizCanvas: HTMLCanvasElement = document.createElement("canvas");
jeelizCanvas.id = "jeelizCanvas";
document.body.appendChild(jeelizCanvas).style.display = "none";
その後初期化を行います.
canvasId 生成したcanvasのIdを指定
NNC NNのモデルとして読み込んだjsonファイルを指定
videoSettings
deviceId WebカメラのdeviceIdを設定
再設定する方法が不明
code:ts
// == jeeliz =========================================================================================
const jeelizCanvas: HTMLCanvasElement = document.createElement("canvas");
jeelizCanvas.id = "jeelizCanvas";
document.body.appendChild(jeelizCanvas).style.display = "none";
faceTransferAPI.init({
canvasId: "jeelizCanvas",
NNC: neuralNetworkModel, //instead of NNCpath
//... other init parameters
videoSettings: {
// 'videoElement'//not set by default. <video> element used
//If you specify this parameter,
//all other settings will be useless
//it means that you fully handle the video aspect
'deviceId': deviceId, //not set by default
'facingMode': 'user', //to use the rear camera, set to 'environment'
'idealWidth': 800, //ideal video width in pixels
'idealHeight': 600, //ideal video height in pixels
'minWidth': 480, //min video width in pixels
'maxWidth': 1280, //max video width in pixels
'minHeight': 480, //min video height in pixels
'maxHeight': 1280, //max video height in pixels,
'rotate': 0, //rotation in degrees possible values: 0,90,-90,180
'flipX': false //if we should flip horizontally the video. Default: false
},
callbackReady: (errCode: any) => {
if (errCode) {
console.log('AN ERROR HAPPENS. ERROR CODE =', errCode);
return;
}
faceTransferAPI.switch_displayVideo(false);
console.log("Jeeliz is Ready");
},
});
code:tst
faceTransferAPI.switch_displayVideo(false);
で認識後のカメラ映像を非表示に出来ます。
jeelizからVRMのボーンに適用する
jeelizで認識した頭部の回転をVRMに適用します。
faceTransferAPI.get_rotationStabilized();で頭部の回転を取得し、腰と首と頭のボーンに分散して適用させています。
体も動くので少し自然にみえると思います
code:ts
const faceRotaion = faceTransferAPI.get_rotationStabilized();
...
const applyHeadRotation = (rotaions: Array<number>) => {
let xd = -1, yd = 1, zd = -1;
if (SETTINGS.isMirror) {
yd = -1;
zd = 1;
}
let faceRotaion = [
SETTINGS.headOffset.x + xd * rotaions0, SETTINGS.headOffset.y + yd * rotaions1, SETTINGS.headOffset.z + zd * rotaions2 ];
const headW = 0.7;
const neckW = 0.2;
const spineW = 0.1;
if (bones.head) {
bones.head.rotation.x = faceRotaion0 * headW; bones.head.rotation.y = faceRotaion1 * headW; bones.head.rotation.z = faceRotaion2 * headW; }
if (bones.neck) {
bones.neck.rotation.x = faceRotaion0 * neckW; bones.neck.rotation.y = faceRotaion1 * neckW; bones.neck.rotation.z = faceRotaion2 * neckW; }
if (bones.spine) {
bones.spine.rotation.x = faceRotaion0 * spineW; bones.spine.rotation.y = faceRotaion1 * spineW; bones.spine.rotation.z = faceRotaion2 * spineW; }
}
jeelizからVRMの表情に適用する
jeelizで認識した表情をVRMに適用します。
get_morphTargetInfluencesStabilized()で認識した表情の値を取得できます。
code:ts
const faceExpression = faceTransferAPI.get_morphTargetInfluencesStabilized();
認識できる表情とindexは以下の通りです。
0: smileRight → closed mouth smile right
1: smileLeft → closed mouth smile left
2: eyeBrowLeftDown → eyebrow left frowned
3: eyeBrowRightDown → eyebrow right frowned
4: eyeBrowLeftUp → eyebrow left up (surprised)
5: eyeBrowRightUp → eyebrow right up (surprised)
6: mouthOpen → mouth open
7: mouthRound → mouth round
8: eyeRightClose → close right eye
9: eyeLeftClose → close left eye
10: mouthNasty → mouth nasty (upper lip raised)
取得した表情をVRMに適用します。
リップシンクの部分は排他的になるようにしており、もっとも値が大きい表情以外は適用されないようにしています。
VRoidStudioのデフォルトモデルなど同時に適用すると破綻してしまうモデルが存在します。
code:ts
//jeelizの表情データをVRM用に変換する。
const rawExpressions = convertExpression(faceExpression);
const filteredExpressions = applyThreshold(rawExpressions);
applyExpression(filteredExpressions);
...
interface FaceBlendshape {
};
const convertExpression = (faceExpression: any): FaceBlendshape => {
const rawExpressions: FaceBlendshape = {
"a": faceExpression6 || 0, "i": faceExpression10 || 0, "u": faceExpression7 || 0, "o": (faceExpression6 + faceExpression6 * faceExpression7) * 0.5 || 0, };
return rawExpressions;
}
// リップシンクの排他制御を行う
// TODO:入力データに閾値を適用する。
const applyThreshold = (rawExpressions: FaceBlendshape): FaceBlendshape => {
let max = 0;
ripKey.forEach(key => {
if (rawExpressionskey > max) { } else {
}
}
});
return rawExpressions;
}
// //表情状態をモデルに適用する。
const applyExpression = (filteredExpressions: any): void => {
if (currentVRM.blendShapeProxy) {
currentVRM.blendShapeProxy.setValue("blink_r", filteredExpressions"blink_r"); currentVRM.blendShapeProxy.setValue("blink_l", filteredExpressions"blink_l"); ...
}
}
jeelizは眉の状態も認識できるのでBlendshape名を拡張すると面白いと思います。
SkyWay Conferenceでcanvasの内容の送信
カメラ映像を送る代わりにthree.jsのcanvasの内容をWebRTCで送信します。
WebRTCクライアントとしての機能はSkyWayConfで完成されているので映像を送る部分を差し替えるだけです。
まず送信用のcanvasを用意します。
code:ts
interface CanvasElement extends HTMLCanvasElement {
captureStream(): MediaStream;
}
export const threeCanvas: CanvasElement = renderer.domElement as unknown as CanvasElement;
export const drawCanvas: CanvasElement = document.createElement("canvas") as unknown as CanvasElement;
drawCanvas.width = SETTINGS.canvas.width;
drawCanvas.height = SETTINGS.canvas.height;
const updateImgage = () => {
drawCanvas.getContext('2d')!.drawImage(threeCanvas, 0, 0);
}
// 描画ループ
const clock = new THREE.Clock();
let currentReqID: number;
const update = () => {
const delta = clock.getDelta();
if (currentVRM) {
faceDetection();
currentVRM.update(delta);
}
renderer.render(threeScene, camera);
updateImgage();
currentReqID = requestAnimationFrame(update);
}
わざわざthree.jsのcanvasを別のcanvasにコピーしている理由は後述します。
src > conf > utils > webrtc.tsを変更しカメラ映像の代わりにcanvasの内容を送るようにします。
code:ts
import { drawCanvas, initFaceDetection } from "../vrm/scene";
...
export const getUserVideoTrack = async (
deviceId: string
): Promise<MediaStreamTrack> => {
//const constraints =
// deviceId === "" ? true : { deviceId: { exact: deviceId } };
initFaceDetection(deviceId);
// return navigator.mediaDevices
// .getUserMedia({ video: constraints })
// .then((stream) => stream.getVideoTracks()0); return drawCanvas.captureStream().getVideoTracks()0; };
これでアバターWeb会議に必要な機能がほぼ実現できます。
はまったところ、問題
x.icon解決できた問題、_.icon未解決な問題など
_.icon jeelizの認識に使うカメラデバイスを変更できない
一度faceTransferAPI.init()で設定したdeviceIdを変更する方法がわかりませんでした。
変更するメソッドも見つからないうえ、一度initを実行しても初期化できません。
x.iconアバター画像にカメラ画像が点滅して表示される
原因不明
送信用の映像に(送信するつもりのない)カメラ画像が点滅するように表示されてしまう問題が起こりました。
いったん別のcanvasにコピーして送信することで発生しなくなりました。
他の方法を試そうと思ったのですが再現性が低く対策は保留中です。
まとめ
SkyWayConfによって自分が欲しいWebRTCアプリを簡単に作ることができました。
アバター以外にも表情認識を拡張したり色々面白いことができそうです。
使い方
参考