はじめに
この記事は VTuber Tech #1 Advent Calendar 2018 15 日目の記事です。
お疲れ様です。CyberVの新卒Unityエンジニアのイワケンと申します。普段はVTuberの演出や運用の技術サポートをしています。
今回は趣味で作った「VTuberの口の動きを再現するScript」についてご紹介します。
補足
デモ動画を載せたかったのですが、AIUEOのBlendShapeが入った配布モデルを探すのが12/15に間に合いませんでした... 見つかり次第動画載せます!
12/17
自分のPCでマイクが使える状況を探し中です...!
背景
VTuber収録の手法
VTuberの収録では、何らかの技術や手段で人間の動きを3Dアバターに反映させることによって、3Dのアバターが人間のように動き喋る姿を収録できるようになります。
例えば、僕が昔作った動画がこちらです。
https://youtu.be/V59IN5Yr2Oo
人間の動きを反映させる箇所をグループ分けすると
・全身の動き (Humanoid型のBone)
・指の動き
・表情
・口の動き (表情に含まれるときもある)
・視線
などに分けられます。
これらを反映させるために、様々なデバイスや技術が試されています。
全身の動き | 指の動き | 表情 | 口の動き | 視線 | |
---|---|---|---|---|---|
デバイス and 技術の例 | Perception Neuron,MVN,OptiTrack | VIVEコントローラー,Manus VR,Cobra Glove | UIによる選択,OpenCV × Dlib | OVRLipSync,AniLipSync,FaceTracking | カメラ目線,魔法 |
上記の映像の採用技術 | Perception Neuron | Perception Neuron | 自動瞬きのみ | OVRLipSync | 前を向くのみ |
どのデバイス and 技術を使用するかは、表現したいVTuberに対して、予算とリソース(収録スペースを確保できるかなども含め)に加え、エンジニアの技術力と好みによって決定されるでしょう。
共通していることは、ほとんどのケースで、これらのデータはUnityのEditor,もしくはUnityによってビルドされたアプリケーションに集約され、映像として表現されます。
(UnrealEngine4を採用している人/企業もいますが、おそらくUnityの採用例の方が多いのと、私はUnrealEngine4の説明をできないので、今回はUnityについてのみ説明します)
あの時の...が欲しい!(VTuberの再現性)
VTuberのよくあるの収録方法は、通常OBSやXSplitなどの画面キャプチャソフトを用いた一発撮りです。その現場は、実写の撮影現場に近いものがあり、先程の全身の動き,指の動き,表情,口の動き,視線が完璧に組み合わさった3Dアバターを3D空間上のカメラによってレンダリングされた映像を映像として保存することで動画投稿や生放送を行うことができます。
もう一度いいますが、一発撮りの場合、これらが完璧に組み合わさっていなければなりません。例えば、全身の動きも指も表情もよくても、口が動いてなかったら撮り直しになります。実写の撮影と近いものがあります。
こういった一発撮りは、クオリティの高い動画(例: MV)を作ろうとすればするほど難易度があがってきます。そこで、部分部分を保存してあとから編集する手法が考えられます。
例えば、映像編集の世界でも、本体と背景を別素材で撮ってあとから編集で合わせることがあります。
同様に、全身の動き,指の動き,表情,口の動き,視線,カメラワークも別々で保存して、最後Unity上で合わせる手法を考えてみたいと思いました。
今回の記事では、口の動きをUnity上で保存して再現するScriptについて、紹介します。
Unity上で保存することで、任意のデバイスや技術に対しても適用することができ、最終的に同じ動きを再現することができます。
関連技術
全身の動きの保存 & 再生
DUOさんが配布しているEasyMotionRecorderは、全身の動きをUnity上で保存して再生することができます。今回のScriptもEasyMotionRecorderを参考にしてます。
音声の保存による再現
口の動きを保存する仕組み以外に口の動きを再現する方法として、音声を保存し再度LipSyncに適用させて口の動きを再現する手法があります。
この手法は、音源と動きのタイミングが決まっている場合 (歌やダンスなど) には有効ですが、そうでない場合めんどうくさい手法になります。
デモ
デモ...したかったのですが、無料でBlendShapeでAIUEOが入っているモデルを探しています!!!あれば動画載せます。
書いてみたコード
解説後ほど更新します!
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class LipDataSeaquence : ScriptableObject
{
[System.SerializableAttribute]
public class SerializeLipData
{
public int FrameCount;
public float Time;
public LipBlendShape lipBlendShape;
[System.Serializable]
public class LipBlendShape
{
[Range(0f, 100f)]
public float[] blendShapes = new float[5];
public LipBlendShape(float[] weight)
{
if (weight.Length != 5)
{
return;
}
for (int i = 0; i < weight.Length; i++)
{
blendShapes[i] = Mathf.Clamp(weight[i], 0, 100);
}
}
}
}
public BlendShapeIndex[] blendShapesIndex;
public List<SerializeLipData> serializeLipDatas = new List<SerializeLipData>();
}
[System.Serializable]
public class BlendShapeIndex
{
public string Name;
public int[] AIUEOIndex = new int[5];
}
[System.Serializable]
public class MeshBlendShapeIndex
{
public SkinnedMeshRenderer skinnedMeshRenderer;
public BlendShapeIndex blendShapeIndex;
}
}
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using UnityEditor;
using UnityEngine;
public class LipDataRecorder : MonoBehaviour
{
[SerializeField] string fileName = "Name";
[SerializeField] KeyCode recordStartKey = KeyCode.R;
[SerializeField] KeyCode recordStopKey = KeyCode.X;
[SerializeField]
MeshBlendShapeIndex[] meshBlendShapes;
[SerializeField] bool recording = false;
[SerializeField] int frameIndex = 0;
Action onRecordEnd;
float recordedTime = 0;
LipDataSeaquence lipDataSeaquence;
DateTime startDataTime;
void Update()
{
if (Input.GetKeyDown(recordStartKey))
{
RecordStart();
}
if (Input.GetKeyDown(recordStopKey))
{
RecordEnd();
}
}
public void RecordStart()
{
if (recording == false)
{
if(meshBlendShapes == null || meshBlendShapes.Length == 0){
return;
}
frameIndex = 0;
recordedTime = 0;
lipDataSeaquence = ScriptableObject.CreateInstance<LipDataSeaquence>();
lipDataSeaquence.blendShapesIndex = new BlendShapeIndex[meshBlendShapes.Length];
for (int i = 0; i < meshBlendShapes.Length;i++){
lipDataSeaquence.blendShapesIndex[i] = meshBlendShapes[i].blendShapeIndex;
}
onRecordEnd += WriteLipDataFile;
recording = true;
startDataTime = DateTime.Now;
}
}
public void RecordEnd()
{
if (recording)
{
if (onRecordEnd != null)
{
onRecordEnd();
onRecordEnd = null;
}
recording = false;
}
}
void LateUpdate()
{
if (recording)
{
recordedTime += Time.deltaTime;
var serializedLip = new LipDataSeaquence.SerializeLipData();
serializedLip.lipBlendShape = new LipDataSeaquence.SerializeLipData.LipBlendShape(GetCurrentLipBlendShape());
serializedLip.FrameCount = frameIndex;
serializedLip.Time = recordedTime;
lipDataSeaquence.serializeLipDatas.Add(serializedLip);
frameIndex++;
}
}
void WriteLipDataFile()
{
string filePath = "Assets/LipDataRecorder/Resources/RecordingData/" + startDataTime.ToString("yyyy_MMdd");
SafeCreateDirectory(filePath);
string path = AssetDatabase.GenerateUniqueAssetPath(
filePath + "/" + fileName + "_" + startDataTime.ToString("HHmmss") +
".asset");
AssetDatabase.CreateAsset(lipDataSeaquence, path);
AssetDatabase.Refresh();
frameIndex = 0;
recordedTime = 0;
}
float[] GetCurrentLipBlendShape(){
float[] output = new float[5];
for (int i = 0; i < output.Length; i++){
output[i] = meshBlendShapes[0].skinnedMeshRenderer.GetBlendShapeWeight(meshBlendShapes[0].blendShapeIndex.AIUEOIndex[i]) * 100;
}
return output;
}
public static DirectoryInfo SafeCreateDirectory(string path)
{
if (Directory.Exists(path))
{
return null;
}
return Directory.CreateDirectory(path);
}
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class LipDataPlayer : MonoBehaviour
{
[SerializeField] KeyCode playStartKey = KeyCode.S;
[SerializeField] KeyCode playStopKey = KeyCode.T;
[SerializeField] LipDataSeaquence recordingLipData;
[SerializeField] MeshBlendShapeIndex[] LipMeshBlendShapes;
System.Action onPlayFinish;
[SerializeField]
[Tooltip("再生開始フレームを指定します。0だとファイル先頭から開始です")]
int startFrame = 0;
[SerializeField] bool playing = false;
[SerializeField] int frameIndex = 0;
float playingTime = 0;
public void PlayLip()
{
if (playing == false)
{
if (recordingLipData == null)
{
Debug.LogError("録画済みLipDataが指定されていません。再生を行いません。");
}
else
{
playingTime = startFrame * (Time.deltaTime / 1f);
frameIndex = startFrame;
playing = true;
}
}
}
public void StopLip()
{
if (playing)
{
playingTime = 0;
frameIndex = startFrame;
playing = false;
}
}
void SetLip()
{
var lipData = recordingLipData.serializeLipDatas[frameIndex].lipBlendShape;
for (int i = 0; i < LipMeshBlendShapes.Length;i++){
for (int j = 0; j < 5;j++){
LipMeshBlendShapes[i].skinnedMeshRenderer.SetBlendShapeWeight(recordingLipData.blendShapesIndex[i].AIUEOIndex[j],lipData.blendShapes[j]);
}
}
//処理落ちしたモーションデータの再生速度調整
if (playingTime > recordingLipData.serializeLipDatas[frameIndex].Time)
{
frameIndex++;
}
if (frameIndex == recordingLipData.serializeLipDatas.Count - 1)
{
if (onPlayFinish != null)
{
onPlayFinish();
}
}
}
// Use this for initialization
void Awake()
{
onPlayFinish += StopLip;
}
// Update is called once per frame
void Update()
{
if (Input.GetKeyDown(playStartKey))
{
PlayLip();
}
if (Input.GetKeyDown(playStopKey))
{
StopLip();
}
}
private void LateUpdate()
{
if (!playing) return;
playingTime += Time.deltaTime;
SetLip();
}
}