Nous avions besoin d’animer des personnes en 3D pour nos projets. Une des solutions auraient été de faire image par image l’animation sur un logiciel 3D comme par exemple Blender. Mais cela demande énormément de temps et cela n’offre pas forcément un rendu naturel.
Une autre solution aurait été de louer un studio de motion capture.
La qualité des rendus des animations est excellente mais vient avec un coût conséquent en termes de temps-opérateur et de frais matériels. De plus, si des changements sont requis dans l’animation, ceux-ci peuvent facilement engendrer des frais importants, en demandant de re-préparer un set d’enregistrement, avec les frais que cela implique.
Comme nous avions besoin d’une solution simple, rapide et efficace pour enregistrer nos animations. La synthèse totale sous Blender ainsi que la location d’un studio de motion capture n’étaient pas adaptés à nos besoins donc nous avons créer notre propre solution : utiliser le casque de réalité virtuelle Oculus Quest avec ses contrôleurs 6 dimensions (6DoF) pour faire la capture de mouvement en combinaison avec un casque audio pour enregistrer la voix.
Et devinez quoi? Le résultat est plutôt bon !
Alors voilà la solution
Logique globale
Notre studio « home-made » de capture du mouvements est séparé en 3 parties.
- Une application client embarquée sur l’Oculus Quest qui au clic sur une manette, enregistre la position / rotation des manettes et du casque et envoie ces données télémétriques à un serveur NodeJS.
- Un serveur NodeJS avec socket.io qui s’occupe de faire les transmissions des données télémétriques entre notre Oculus Quest et l’application éditeur.
- Une application « Editeur » qui reçoit les données télémétriques de l’Oculus Quest et enregistre des animations en combinaison avec le spectre audio de la voix de l’animateur.
Pour l’ocassion, nous avons fait l’acquisition d’un bon casque audio-micro wifi (HS70-Corsair) qui est directement connecté à l’ordinateur qui exécute l’éditeur. Il est aussi possible d’enregistrer le son depuis l’Oculus Quest mais un signal audio peut être assez lourd et l’on évite aussi les problèmes de synchronisation en connectant directement le micro sans-fil à l’éditeur.
Comme nous avons utilisé un package payant pour les communications serveurs, nous ne pouvons pas mettre à disposition le code sur GitHub. Mais voici comment nous avons fait et le code qui vous permettra de faire la même chose !
Le client sur l’Oculus Quest
En premier, pour toute la partie communication entre le client, serveur et l’éditeur nous avons utilisé socket.io qui permet une communication instantanée.
Pour implémenter socket.io dans l’éditeur et le client Unity, nous avons préféré utilisé un package payant d’excellente qualité : BestHTTP Pro
https://assetstore.unity.com/packages/tools/network/best-http-10872
L’utilisation de ce package permet une implémentation très simple de socket.io et de se concentrer sur d’autres tâches. Il y a bien assez à développer (;
Concernant le code sur notre application client, à savoir l’application embarquée sur l’Oculus Quest, c’est très simple. Un simple projet Unity où nous enregistrons juste la position / rotation des contrôleurs et de la tête et nous envoyons tout ça via socket.io à notre serveur NodeJS à travers un seul script.
Le code « socketClient.cs » :
using BestHTTP.SocketIO;
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[Serializable]
public class telemetry_packet
{
public TF left_hand;
public TF right_hand;
public TF head;
}
[Serializable]
public class TF
{
public Vector3 position;
public Quaternion rotation;
public TF(Transform t)
{
this.position = t.localPosition;
this.rotation = t.localRotation;
}
}
public class socketClient : MonoBehaviour
{
SocketManager Manager;
public const string localSocketUrl = "http://localhost:7000/socket.io/";
public const string webSocketUrl = "http://yourInternetEndPoint:7000/socket.io/";
telemetry_packet tp;
public GameObject right_hand;
public GameObject left_hand;
public GameObject head;
// Start is called before the first frame update
void Start()
{
timeFrame = 1f / fps;
timeTelemetry = timeFrame;
SocketOptions options = new SocketOptions();
options.AutoConnect = false;
options.ConnectWith = BestHTTP.SocketIO.Transports.TransportTypes.WebSocket;
Manager = new SocketManager(new Uri(webSocketUrl), options);
Manager.Socket.On("connect", OnConnect);
Manager.Socket.On("disconnect", OnDisconnect);
Manager.Open();
}
bool connect = false;
bool record = false;
void OnConnect(Socket socket, Packet packet, params object[] args)
{
tp = new telemetry_packet();
connect = true;
Debug.Log("connected");
}
void OnDisconnect(Socket socket, Packet packet, params object[] args)
{
connect = false;
// args[0] is the nick of the sender
// args[1] is the message
Debug.Log("disconnected");
}
bool click = false;
float time = 0f;
float fps = 60;
float timeFrame = 0f;
float timeTelemetry = 0f;
public Material skybox;
IEnumerator Tele ()
{
tp.head = new TF(head.transform);
tp.right_hand = new TF(right_hand.transform);
tp.left_hand = new TF(left_hand.transform);
string json = JsonUtility.ToJson(tp);
yield return null;
Manager.Socket.Emit("telemetry", json);
yield return null;
}
// Update is called once per frame
void FixedUpdate()
{
if (Input.GetKeyUp(KeyCode.R) || OVRInput.Get(OVRInput.Button.One))
{
if (!click)
{
time = 0f;
click = true;
if (record)
{
skybox.SetColor("_SkyTint", Color.white);
record = false;
}
else
{
skybox.SetColor("_SkyTint", Color.red);
record = true;
}
if (connect)
{
Manager.Socket.Emit("setrecording", record);
}
}
}
else
{
time += Time.deltaTime;
if (time > 1f)
{
click = false;
}
}
if (connect)
{
timeTelemetry -= Time.deltaTime;
if (timeTelemetry < 0f)
{
timeTelemetry = timeFrame;
StartCoroutine("Tele");
}
}
}
void OnDestroy()
{
skybox.SetColor("_SkyTint", Color.white);
// Leaving this sample, close the socket
if (Manager != null)
{
Manager.Close();
}
}
}
Le serveur
Ici, c’est aussi assez simple. Nous voulons juste recevoir les données télémétriques de notre client et les transmettre à notre éditeur. Pour cela nous avons utilisé NodeJS avec les librairies « express », « fs » et « socket.io » que vous pouvez simplement installer avec NPM.
Utilisez la commande npm install + express, fs, socket.io.
Pour l’hébergement de notre serveur, nous avons utiliser Heroku. Ce service permet gratuitement d’héberger des petits services node.js.
Le code « server.js » :
var express = require("express");
var fs = require('fs');
var app = new express();
var http = require("http").Server(app);
var io = require("socket.io")(http);
app.use(express.static(__dirname + "/public" ));
app.get('/',function(req,res){
res.redirect('index.html');
});
io.sockets.on('connection', function (socket) {
console.log('Someone is connected');
socket.on('setrecording',function(value){
console.log('Recording change to '+value);
io.emit('recording', value);
});
socket.on('telemetry',function(json){
io.emit('telemetry', json);
});
socket.on('disconnect', function() {
console.log('Disconnected');
io.emit('clientdisconnected', true);
});
});
console.log('Server is correctly running for the moment :-)');
http.listen(7000,function(){
console.log("Server running at port "+ 7000);
});
L’éditeur
C’est la partie la plus compliquée. En premier, il faut créer un nouveau projet Unity.
Pour enregistrer l’audio venant de notre casque-micro, nous avons utilisé le projet de « DarkTable » intitulé « SavWav ».
Pourquoi refaire la roue si elle existe déjà !
https://gist.github.com/darktable/2317063
Ensuite la première chose que nous devons faire c’est récupérer les données télémétriques de notre serveur.
Le code « socketMaster.cs » :
using BestHTTP.SocketIO;
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[Serializable]
public class telemetry_packet
{
public TF left_hand;
public TF right_hand;
public TF head;
}
[Serializable]
public class TF
{
public Vector3 position;
public Quaternion rotation;
public TF(Transform t)
{
this.position = t.position;
this.rotation = t.rotation;
}
}
public class socketMaster : MonoBehaviour
{
SocketManager Manager;
Follower follow;
AnimMicroRecorder amr;
public const string localSocketUrl = "http://localhost:7000/socket.io/";
public const string webSocketUrl = "http://yourEndPoint:7000/socket.io/";
// Start is called before the first frame update
void Start()
{
SocketOptions options = new SocketOptions();
options.AutoConnect = false;
options.ConnectWith = BestHTTP.SocketIO.Transports.TransportTypes.WebSocket;
Debug.Log(new Uri(webSocketUrl));
Manager = new SocketManager(new Uri(webSocketUrl), options);
Manager.Socket.On("connect", OnConnect);
Manager.Socket.On("disconnect", OnDisconnect);
Manager.Socket.On("telemetry", Telemetry);
Manager.Socket.On("recording", OnStatusChange);
Manager.Open();
follow = GameObject.FindObjectOfType<Follower>();
amr = GameObject.FindObjectOfType<AnimMicroRecorder>();
}
void OnConnect(Socket socket, Packet packet, params object[] args)
{
Debug.Log("connected");
}
void OnDisconnect(Socket socket, Packet packet, params object[] args)
{
// args[0] is the nick of the sender
// args[1] is the message
Debug.Log("disconnected");
}
void Telemetry(Socket socket, Packet packet, params object[] args)
{
// args[0] is the nick of the sender
// args[1] is the message
string data = (string)args[0];
telemetry_packet tp = JsonUtility.FromJson<telemetry_packet>(data);
if (follow.tp == null)
{
follow.tp = tp;
follow.setHeadOriginal();
}
else
{
follow.tp = tp;
}
}
void OnStatusChange(Socket socket, Packet packet, params object[] args)
{
bool record = (bool)args[0];
// args[0] is the nick of the sender
// args[1] is the message
Debug.Log("OnStatusChange "+ record);
if (record)
{
follow.record = true;
follow.setHeadOriginal();
amr.startRecording();
}
else
{
amr.stopRecording();
follow.record = false;
}
}
void OnDestroy()
{
// Leaving this sample, close the socket
if (Manager != null)
{
Manager.Close();
}
}
// Update is called once per frame
void Update()
{
}
}
Quand l’on reçois des données JSON depuis le serveur, on les décomprime dans une classe et on les envoies à l’objet « Follower » qui va imiter les mouvements du « Client ».
Quand nous recevons les données télémétriques en JSON de notre serveur, nous les décompressons dans une classe et les envoyons à un objet intitulé « Follower ». L’objet Follower va imiter exactement les mêmes mouvements de position et de rotations que nous enregistrerons du côté « Client ».
Le code « Follower.cs » :
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Follower : MonoBehaviour
{
[HideInInspector]
public telemetry_packet tp;
public Transform root;
TF original_pos;
// Start is called before the first frame update
void Start()
{
original_pos = new TF(root);
}
public GameObject right_hand;
public GameObject left_hand;
public GameObject head;
public bool record = false;
Vector3 originalHeadPosition = Vector3.zero;
public void setHeadOriginal ()
{
if (tp != null)
{
originalHeadPosition = tp.head.position;
}
}
// Update is called once per frame
void FixedUpdate()
{
if (tp != null)
{
Vector3 right = tp.right_hand.position - originalHeadPosition;
//right.y = tp.right_hand.position.y;
right_hand.transform.localPosition = right;
Vector3 left = tp.left_hand.position - originalHeadPosition;
//left.y = tp.left_hand.position.y;
left_hand.transform.localPosition = left;
head.transform.localPosition = tp.head.position - originalHeadPosition;
right_hand.transform.rotation = tp.right_hand.rotation;
left_hand.transform.rotation = tp.left_hand.rotation;
head.transform.rotation = tp.head.rotation;
}
}
}
Maintenant lorsque un contrôleur bouge sur notre client, il bouge aussi dans notre éditeur, nous n’avons plus qu’à sauvegarder l’animation et le son. Pour enregistrer l’animation, nous utilisons une fonction de l’éditeur disponible dans Unity nommée « GameObjectRecorder » qui permet l’enregistrement d’une animation de façon native. Pour enregistrer le son, nous utiliserons le projet de « DarkTable » intitulé « SavWav » dont vous trouverez le lien plus haut.
Le code « AnimMicroRecorder.cs » :
using System.Collections;
using System.Collections.Generic;
using UnityEditor;
using UnityEditor.Animations;
using UnityEngine;
public class AnimMicroRecorder : MonoBehaviour
{
private AnimationClip clip;
private GameObjectRecorder m_Recorder;
public GameObject root_target;
AudioClip _RecordingClip;
int _SampleRate = 44100; // Audio sample rate
int _MaxClipLength = 300; // Maximum length of audio recording
public float _TrimCutoff = .01f; // Minimum volume of the clip before it gets trimmed
public Material mat_skybox;
// Start is called before the first frame update
void Start()
{
}
float timeRecord = 0f;
bool record = false;
float timefps = 0f;
public void startRecording ()
{
mat_skybox.SetColor("_SkyTint", Color.red);
Debug.Log("Start Recording");
clip = new AnimationClip();
m_Recorder = new GameObjectRecorder(root_target);
m_Recorder.BindComponentsOfType<Transform>(root_target, true);
timeRecord = 0f;
timefps = 1f / fps;
timeFrame = 0f;
record = true;
if (Microphone.devices.Length > 0)
{
_RecordingClip = Microphone.Start("", true, _MaxClipLength, _SampleRate);
}
}
int number = 1;
public void stopRecording ()
{
mat_skybox.SetColor("_SkyTint", Color.white);
Debug.Log("Stop Recording");
record = false;
m_Recorder.SaveToClip(clip, 30f);
string fileName = System.DateTime.Now.ToString("dd-hh-mm-ss");
string name = fileName + ".anim";
AssetDatabase.CreateAsset(clip, "Assets/Resources/Recordings/Animations/"+ name);
AssetDatabase.SaveAssets();
if (Microphone.devices.Length > 0)
{
Microphone.End("");
var samples = new float[_RecordingClip.samples];
_RecordingClip.GetData(samples, 0);
List<float> list_samples = new List<float>(samples);
int numberOfSamples = (int)(timeRecord * (float)_SampleRate);
list_samples.RemoveRange(numberOfSamples, list_samples.Count - numberOfSamples);
var tempclip = AudioClip.Create("TempClip", list_samples.Count, _RecordingClip.channels, _RecordingClip.frequency, false, false);
tempclip.SetData(list_samples.ToArray(), 0);
string path = Application.dataPath + "\\Resources\\Recordings\\Animations\\" + fileName;
SavWav.Save(path, tempclip);
}
}
float fps = 30f;
float timeFrame = 0f;
// Update is called once per frame
void FixedUpdate()
{
if (Input.GetKeyDown(KeyCode.R))
{
if (record)
{
stopRecording();
}
else
{
startRecording();
}
}
if (record)
{
timeRecord += Time.deltaTime;
timeFrame += Time.deltaTime;
timefps -= Time.deltaTime;
if (timefps < 0f)
{
//Debug.Log("fps");
m_Recorder.TakeSnapshot(timeFrame);
timefps = 1f / fps;
timeFrame = 0f;
}
}
// Take a snapshot and record all the bindings values for this frame.
}
void OnDestroy()
{
mat_skybox.SetColor("_SkyTint", Color.white);
}
}
Conclusion
En conclusion, vous avez maintenant toutes les pièces qu’il vous faut pour réaliser votre propre studio de captures de mouvements avec un Oculus Quest et un casque audio avec micro, ça c’est la magie. Notre seule limite est notre imagination désormais !
Pour aller plus loin, vous pouvez animer la bouche de votre personnage avec un package nommé « Salsa Sync » aussi disponible dans l’Asset Store d’Unity. Facile à utiliser et visuellement réaliste! Pour enregistrer la vidéo de démonstration, nous avons utilisé le package Unity nommé « Unity Recorder ». Vous pouvez le trouver dans le gestionnaire de package d’Unity.
N’hésitez pas à nous poser vos questions. Si vous ajoutez des améliorations à ce projet et / où enregistrez des animations, n’hésitez pas à nous envoyez vos résultats.