I. Introduction▲
Une poignée d'applications mobiles natives telles que Snapchat, Facebook Lenses ou MSQRD ont popularisé les filtres webcam faciaux. Certaines disposent même de studios permettant de créer simplement ses propres filtres sans nécessiter de connaissances particulières en développement. Il est toujours temps pour vous de prendre la pilule bleue tendue par Morpheus en lançant Facebook AR Studio et en arrêtant ce tutoriel. Vous souhaitez continuer ? Alors prenez la pilule rouge et accrochez-vous quand on abordera WebGL ! Votre filtre ne sera plus alors cantonné à la Matrix, mais pourra être embarqué où bon vous semble grâce au miracle du JavaScript.
Vous pouvez tester vous-même le filtre facial que nous allons étudier sur https://jeeliz.com/demos/faceFilter/demos/threejs/matrix/. Si vous n'avez pas de webcam, une vidéo screenshot est consultable ici :
Cliquez pour lire la vidéo
Vous pouvez télécharger le projet final issu de ce tutoriel ici.
II. Initialisation du projet▲
Pour réaliser ce tutoriel, vous devez avoir un serveur HTTP local installé. Pour lancer le projet depuis un domaine autre que localhost, il sera nécessaire de le servir en HTTPS, autrement l'accès à la webcam ne sera pas autorisé par le navigateur. Nous débutons avec un fichier index.html contenant le code suivant :
L'élément <canvas> est l'endroit où le filtre facial sera rendu. La propriété CSS transform: rotateY(180deg); permet d'afficher l'image de la webcam façon miroir. Nous appelons un script, main.js, qui contient le point d'entrée et qui redimensionne le <canvas> en plein écran :
II-A. Accès à la webcam et détection du visage▲
Nous allons utiliser Jeeliz FaceFilter pour accéder à la webcam, détecter le visage et son orientation. Cette librairie utilise un réseau neuronal d’apprentissage profond (deep learning) pour détecter à partir d'une image s'il s'agit d'un visage, sa rotation, sa translation et l'ouverture de la bouche. Elle tourne sur le GPU grâce à WebGL et requiert donc cette capacité. Bien qu'il soit possible de fournir en entrée un élément <video>, il est conseillé de recourir à FaceFilter pour accéder à la webcam de l'utilisateur. En effet, de nombreux polyfills ont été implémentés pour combler les défauts d'application du standard WebRTC entre les différentes configurations possibles.
Nous incluons FaceFilter dans la section <head> de index.html :
<script src
=
'https://appstatic.jeeliz.com/faceFilter/jeelizFaceFilter.js'
></script>
Et nous initialisons FaceFilter dans main.js, à la fin de la fonction main() :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
JEEFACEFILTERAPI.init
({
canvasId
:
'matrixCanvas'
,
//chemin de NNC.json, le modèle de réseau neuronal :
NNCpath
:
'https://appstatic.jeeliz.com/faceFilter/'
,
callbackReady
:
function(
errCode,
etatInitialisation){
if (
errCode){
console.log
(
'HEY, IL Y A EU UNE ERREUR ='
,
errCode);
return;
}
console.log
(
'JEEFACEFILTERAPI MARCHE YEAH !'
);
init_scene
(
etatInitialisation);
},
//end callbackReady()
callbackTrack
:
callbackTrack
}
);
function init_scene
(
etatInitialisation){
//vide pour le moment
}
Nous déclarons la fonction callbackTrack() après la fonction main. Elle sera appelée à chaque exécution de la boucle de détection, soit environ 50 fois par seconde. Son argument est l'état de la détection faciale, etatDetection :
function callbackTrack
(
etatDetection){
console.log
(
etatDetection.
detected);
}
Assez codé, passons au premier test : lancez le code avec la console JavaScript ouverte et acceptez de partager la webcam. Cachez la webcam avec votre main. La valeur loguée dans la console, etatDetection.detected, doit avoisiner 0. Puis libérez votre webcam, placez-vous bien en face de l'objectif et la valeur loguée doit grimper jusqu'à atteindre 1.
II-B. Ajout de la 3D▲
II-B-1. Affichage de la vidéo▲
La Matrix n'étant pas qu'en 2D, nous allons ajouter la 3e dimension. Dans index.html, nous incluons le moteur 3D THREE.js ainsi que le helper de FaceFilter spécifique à THREE.js. Concrètement, nous ajoutons dans la section <head> :
Le helper va notamment créer la scène avec THREE.js, convertir les coordonnées 2D du cadre de détection de la tête en coordonnées 3D dans la scène, créer un objet par visage détecté en gérant la position du point de pivot. Dans main.js nous remplissons la fonction init_scene() :
2.
3.
4.
5.
6.
7.
8.
9.
10.
var THREECAMERA;
function init_scene
(
etatInitialisation){
var threeInstances=
THREE.
JeelizHelper.init
(
etatInitialisation);
//création de la caméra ayant 20 degrés de champ de vision
var cv=
etatInitialisation.
canvasElement;
var aspecRatio=
cv.
width/
cv.
height;
THREECAMERA=
new THREE.PerspectiveCamera
(
20
,
aspecRatio,
0
.
1
,
100
);
}
Dans la fonction callbackTrack, nous déclenchons le rendu d'une image de la scène par la caméra THREECAMERA en remplaçant le console.log par :
THREE.
JeelizHelper.render
(
etatDetection,
THREECAMERA);
Testez votre code. Vous devez voir l'image issue de la webcam en plein écran.
II-B-2. Remplacement de la vidéo▲
Nous allons commencer par remplacer la vidéo webcam par la vidéo de la pluie de lignes de code verdâtres de la Matrix. L'affichage de la vidéo issue de la webcam est en fait une instance d'un THREE.Mesh initialisée par THREE.JeelizHelper et ajoutée à la scène. Dans la fonction init_scene, après avoir créé la caméra nous initialisons d'abord une texture vidéo que nous texturons avec le fichier .MP4 des lignes de code tombantes :
2.
3.
4.
5.
6.
7.
8.
9.
10.
var video=
document
.createElement
(
'video'
);
video.
src=
'matrixRain.mp4'
;
video.setAttribute
(
'loop'
,
'true'
);
video.setAttribute
(
'preload'
,
'true'
);
video.setAttribute
(
'autoplay'
,
'true'
);
var videoTexture =
new THREE.VideoTexture
(
video );
videoTexture.
magFilter=
THREE.
LinearFilter;
videoTexture.
minFilter=
THREE.
LinearFilter;
threeInstances.
videoMesh.
material.
uniforms.
samplerVideo.
value=
videoTexture;
Les paramètres videoTexture.magFilter et videoTexture.minFilter spécifient à WebGL la façon dont il doit calculer la couleur d'un texel de la texture en fonction de la position exacte demandée. THREE.NearestFilter retournera la couleur du texel le plus proche, ce sera plus rapide, mais entraînera des artefacts de pixellisation. THREE.LinearFilter spécifie une interpolation linéaire entre les texels voisins de la position demandée.
Lancez le code. La vidéo des lignes tombantes apparaît en plein écran.
II-B-3. Import du masque▲
Nous allons maintenant importer un maillage de visage qui va suivre la tête puis provoquer la déformation des lignes de code tombantes de la vidéo. Nous ajoutons à la fin de la fonction init_scene :
maskMesh.json contient un maillage créé en exportant le modèle 3D de maskMesh.blend fourni dans l'archive ZIP à télécharger du tutoriel en utilisant le script d'export spécifique pour Blender fourni avec THREE.js.
Le loader charge le maillage et appelle une fonction de callback de façon asynchrone avec pour argument une instance de THREE.BufferGeometry. Nous calculons d'abord ses normales afin de pouvoir appliquer un éclairage cohérent dessus. Puis nous créons une instance de THREE.Mesh, maskMesh, qui est un maillage rendu avec un matériau spécifique à une position donnée dans l'espace. Pour le débogage, nous vous conseillons d'importer toujours vos maillages en leur appliquant le matériau THREE.MeshNormalMaterial() pour d'abord régler les éventuels problèmes d'import, de topologie, de normales, de positionnement ou d'échelle avant de vous concentrer sur le rendu. Enfin, nous ajoutons le THREE.Mesh à l'objet suivant le visage détecté et retourné par le helper en exécutant : threeInstances.faceObject.add(maskMesh);.
Testez le code ainsi obtenu. Le maillage de visage doit suivre le visage.
II-B-4. Changement du matériau du masque▲
II-B-4-a. À propos des shaders…▲
Nous allons maintenant remplacer le matériau du visage par un matériau affichant une combinaison :
- de la texture de l'arrière-plan déplacé (les lignes de code déformées par le masque en 3D) ;
- de la texture vidéo avec la vraie image de votre visage.
Nous allons définir ce matériau par deux bouts de code exécutés sur la carte graphique et appeler les shaders :
- le shader de vertex est exécuté pour chaque point du maillage. Il calcule les coordonnées du point dans le référentiel de la caméra, puis les projette sur la zone de rendu (appelée le viewport) en 2D ;
- le shader de fragment est exécuté au moins une fois pour chaque pixel du rendu final. Il calcule la couleur de chaque pixel. L'anticrénelage étant activé par défaut, ce shader peut être appelé plusieurs fois à proximité des bordures d'objets. Cela affine la couleur des arêtes par suréchantillonnage et réduit donc le crénelage. Les deux shaders sont déclarés en JavaScript sous forme de chaînes de code en GLSL, un langage dont la syntaxe est proche du C. Grâce à THREE.js, il n'y a normalement pas lieu de s'intéresser aux shaders : dès que nous créons un matériau, une paire de shaders idoine est automatiquement créée, compilée et utilisée pour le rendu du matériau. Mais dans notre cas spécifique, aucun matériau prédéfini ne convient et nous devons donc descendre au cœur de la Matrix et déclarer nos propres shaders.
Ce matériau utilisera deux textures : la texture verdâtre de l'arrière-plan avec les lignes de code et la texture vidéo webcam. Ce matériau sera une instance de THREE.ShaderMaterial, car il viendra s'insérer dans la scène 3D. En effet, THREE.js dispose de deux types de matériaux nécessitant de spécifier le code source des shaders :
- les THREE.RawShaderMaterial : ce type de matériau n'a rien de prédéclaré, vous n'y retrouverez pas les matrices de projection 3D usuelles (projectionMatrix, modelViewMatrix…). C'est utile pour faire du calcul sur GPU, du postprocessing ou des applications spécifiques ;
- les THREE.ShaderMaterial : ce type de matériau prend déjà en compte les matrices de projection 3D et les attributs des points (normales, coordonnées UV…). Dès qu'il s'agit de déclarer un matériau pour changer l'aspect d'un objet 3D de la scène, il vaut mieux utiliser cette solution.
Chaque shader doit comporter une fonction void main(void), qui sera exécutée pour chaque point projeté pour le shader de vertex et pour chaque pixel rendu pour le shader de fragment. Cette fonction ne retourne aucune valeur, mais elle doit affecter une variable préconstruite :
- gl_Position pour le shader de vertex qui est la position du point en clipping coordinates dans le viewport ;
- gl_FragColor pour le shader de fragment qui est la couleur du pixel au format RVBA normalisé (chaque composante doit être entre 0 et 1).
II-B-4-b. Notre premier shader▲
Nous remplaçons la ligne affectant maskMaterial par :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
var maskMaterial=
new THREE.ShaderMaterial
({
vertexShader
:
"
\n
\
void main(void){
\n
\
#include <beginnormal_vertex>
\n
\
#include <defaultnormal_vertex>
\n
\
#include <begin_vertex>
\n
\
#include <project_vertex>
\n
\
}"
,
fragmentShader
:
"precision lowp float;
\n
\
uniform vec2 resolution;
\n
\
uniform sampler2D samplerWebcam, samplerVideo;
\n
\
void main(void){
\n
\
vec2 uv=gl_FragCoord.xy/resolution;
\n
\
vec3 colorWebcam=texture2D(samplerWebcam, uv).rgb;
\n
\
vec3 finalColor=colorWebcam;
\n
\
gl_FragColor=vec4(finalColor, 1.); //1 pour l'alpha
\n
\
}"
,
uniforms
:{
samplerWebcam
:
{
value
:
THREE.
JeelizHelper.get_threeVideoTexture
(
)},
samplerVideo
:
{
value
:
videoTexture},
resolution
:
{
value
:
new THREE.Vector2
(
etatInitialisation.
canvasElement.
width,
etatInitialisation.
canvasElement.
height)}
}
}
);
Les shaders, vertexShader et fragmentShader sont déclarés en tant que chaînes de code GLSL. Chaque ligne est terminée par un caractère de saut de ligne, \n puis d'un \ supplémentaire pour signifier qu'il s'agit d'une déclaration d'une chaîne sur plusieurs lignes. La majeure partie de la personnalisation du matériau interviendra dans le shader de fragment. Dans le shader de vertex, nous utilisons les shader chunks. #include <project_vertex> sera par exemple remplacé par la chaîne stockée dans THREE.ShaderChunk.project_vertex. Ce système permet à THREE.JS de réutiliser des portions de code entre plusieurs types de matériaux différents.
Le shader de fragment récupère la couleur de la texture de la vidéo webcam et la stocke dans colorWebcam, puis l'affiche à l'écran en l'affectant à gl_FragColor. La variable gl_FragCoord est une variable préconstruite en GLSL, accessible uniquement en lecture, qui contient les coordonnées en pixels du pixel en cours de rendu sur l'écran. En divisant ses deux premières coordonnées par la résolution en pixels du viewport, on obtient les coordonnées de texture uv normalisées entre 0 et 1.
Les variables de type uniforms permettent de passer des valeurs de JavaScript aux shaders. Nous y retrouvons nos deux textures, ainsi que la résolution du canvas en pixels. Testez le code.
II-B-4-c. Positionnement du masque▲
Le maillage de visage maskMesh est mal positionné par rapport à son objet parent, ce qui explique que dans le rendu précédent le visage apparaît en décalé par rapport au masque. Nous allons donc le déplacer en ajoutant après var maskMesh=new THREE.Mesh(maskGeometry, maskMaterial); :
maskMesh.
position.set
(
0
,
0
.
3
,-
0
.
35
);
Ces coordonnées ont été réglées en rendant faceMesh global (window.faceMesh=faceMesh) et en testant plusieurs positions dans la console JavaScript du navigateur. Le maillage étant centré suivant l'axe horizontal (droite/gauche), il est normal que la première coordonnée soit nulle. La deuxième coordonnée (Y) correspond au décalage suivant l'axe vertical : 0.3 entraîne un rehaussement du masque. Cette valeur a été déterminée en regardant la webcam de face. Enfin la dernière coordonnée, Z, correspond au décalage suivant l'axe de la profondeur. Une coordonnée négative recule le masque par rapport à la caméra. Cette valeur se règle en tournant la tête alternativement à droite et à gauche.
Par chance, l'échelle du maillage est cohérente. Dans le cas contraire, nous aurions pu agrandir ou rétrécir le maillage en modifiant maskMesh.scale de la même façon que la position.
II-B-4-d. Quelques tuyaux à brancher encore▲
Dans fragmentShader, remplacez la ligne vec3 finalColor=colorWebcam; par :
vec3
colorLineCode=
texture2D
(
samplerVideo, uv).rgb;
vec3
finalColor=
colorWebcam+
colorLineCode;
Testons le résultat. Le développement GLSL est très itératif et se prête bien au codage en direct. Son débogage est souvent complexe dès lors qu'il ne s'agit pas d'erreurs syntaxiques mais algorithmiques. Il n'est pas possible d'y insérer de points d'arrêt, d'exécuter pas à pas… Il est donc préférable de procéder par petites touches en testant régulièrement le rendu.
Afin de calculer sereinement la couleur désirée dans le shader de fragment, nous allons avoir besoin de quelques variables supplémentaires :
- vNormalView : vecteur normal au point dans le référentiel de la caméra ;
- vPosition : vecteur position dans le référentiel du masque.
Ces valeurs étant définies pour chaque point, nous allons les affecter dans le shader de vertex et les récupérer interpolées pour chaque pixel dans le shader de fragment. Chaque pixel appartient à une unique face triangulaire. Lors de l'exécution du shader de fragment correspondant au rendu d'un pixel, la valeur de vNormalView par exemple sera interpolée entre les valeurs de vNormalView des trois sommets de la face triangulaire en fonction de la distance du point à chaque sommet.
Nous déclarons ces valeurs de la même façon dans les deux shaders, juste avant le void main(void) :
varying
vec3
vNormalView, vPosition;
Et dans le shader de vertex, nous les affectons à la fin de la fonction main :
vNormalView=
vec3
(
viewMatrix*
vec4
(
normalize
(
transformedNormal), 0
.));
vPosition=
position;
Les variables transformedNormal, viewMatrix et position sont soit des variables déjà déclarées par THREE.JS du fait que nous utilisons un THREE.ShaderMaterial et non un THREE.RawShaderMaterial, soit des variables calculées dans les shader chunks.
II-B-4-e. Ajoutons un peu de réfraction▲
Nous souhaitons que le masque déforme les lignes de code. Pour cela nous allons faire comme si le masque appliquait une réfraction de Descartes sur les lignes de code. Le rayon incident a pour coordonnées vec3(0.,0.,-1.), car l'axe Z (3e coordonnée) est l'axe de la profondeur et il est orienté vers l'arrière de la caméra. Nous nous plaçons dans le référentiel de la caméra (view) où la normale au point est donc vNormalView. Nous utilisons la fonction GLSL refract pour calculer le vecteur directeur du rayon réfracté. Son dernier argument est le ratio des indices de réfraction. 0.3 correspondrait par exemple au passage de l'air (indice de réfraction de 1.0 à un matériau encore plus réfringent que le diamant, d'indice de réfraction 3.33).
Dans le shader de fragment, remplaçons vec3
colorLineCode=
texture2D
(
samplerVideo, uv).rgb; par :
2.
3.
vec3
refracted=
refract
(
vec3
(
0
.,0
.,-
1
.), vNormalView, 0
.3
);
vec2
uvRefracted=
uv+
0
.1
*
refracted.xy;
vec3
colorLineCode=
texture2D
(
samplerVideo, uvRefracted).rgb;
Testons le code. Il y a maintenant une sensation d'interaction avec les lignes de code qui se déforment au passage du visage.
II-B-4-f. Teintons la vidéo de la webcam▲
Nous souhaitons que la vidéo de la webcam soit aussi teintée de vert. Juste après être allé chercher la couleur d'un texel de la vidéo issue de la webcam avec vec3 colorWebcam=texture2D(samplerWebcam, uv).rgb;, nous insérons une ligne pour calculer la valeur (c.-à-d. la luminosité) de la couleur :
float
colorWebcamVal=
dot
(
colorWebcam, vec3
(
0
.299
,0
.587
,0
.114
));
Le vecteur vec3(0.299,0.587,0.114) est le luma, il permet de pondérer les composantes colorimétriques RVB de manière similaire à l'œil humain (voir l'article Wikipédia sur la conversion en niveaux de gris).
Puis nous réaffectons colorWebcam à colorWebcamVal * <couleur verte> * <intensité lumineuse> :
colorWebcam=
colorWebcamVal*
vec3
(
0
.0
,1
.5
,0
.0
);
Testez le code. L'effet n'est pas très esthétique : la couleur est trop verte. Les composantes rouges et bleues du vecteur colorWebcam sont toujours nulles à cause des formules appliquées, et la couleur maximum atteinte sera donc le vert au lieu du blanc.
Nous ajoutons donc un éclairage blanc si la valeur atteint le seuil de 0.3 et saturant à partir de 0.6 :
colorWebcam+=
vec3
(
1
.,1
.,1
.)*
smoothstep
(
0
.3
,0
.6
,colorWebcamVal);
II-B-4-g. Résolution des effets de bordure▲
Les effets de bordure ruinent actuellement le rendu : il n'y a pas de transition entre le masque et l'arrière-plan. La suppression de ces effets est souvent l'aspect le plus difficile lors de la conception de ce type de filtres faciaux. Et c'est pourtant crucial, car ces artefacts nuisent à la cohérence de la scène et produisent un effet de montage en découpé-collé d'un élève de maternelle.
La première étape dans la réduction des effets de bordure consiste à calculer des coefficients valant 1 au niveau des bordures et 0 ailleurs. Plutôt que d'effectuer un calcul complexe pour déterminer un seul coefficient, il est préférable pour le débogage et la simplicité du code d'en calculer plusieurs s'appliquant à différents types de bordures. Nous calculons ainsi au début du shader de fragment, juste après void main(void) :
2.
3.
float
isNeck=
1
.-
smoothstep
(-
1
.2
, -
0
.85
, vPosition.y);
float
isTangeant=
pow
(
length
(
vNormalView.xy),2
.);
float
isInsideFace=(
1
.-
isTangeant)*(
1
.-
isNeck);
- isNeck vaut 1 sur le cou et 0 partout ailleurs. Le cou est caractérisé par une position suivant l'axe vertical (Y) sous un certain seuil. Comme les autres coefficients de bordure, il est préférable qu'il varie progressivement de sorte à éviter l'apparition d'autres effets de bordure quand nous l'utiliserons.
- isTangeant vaut 1 lorsque la face est tangente à la vue et 0 lorsqu'elle est plutôt face à la vue. Nous amplifions l'effet en appliquant un easing simple avec la fonction pow.
- isInsideFace vaut 1 si le pixel rendu est dans le visage, et tend vers 0 au fur et à mesure qu'on s'approche de la bordure du visage.
Uniquement pour le débogage, à la fin de la fonction main du shader de fragment, nous contrôlons la pertinence de nos coefficients par :
gl_FragColor=vec4(isNeck, isTangeant, 0.,1.);
Et cela produit le rendu suivant :
Nous commentons le rendu de débogage pour la suite. Maintenant qu'il nous a permis de vérifier nos coefficients de bordure, nous allons les employer pour implémenter une transition douce entre l'arrière-plan et le masque.
Afin de supprimer la cassure au niveau des lignes de code entre le masque et l'arrière-plan, nous insérons juste avant vec3
colorLineCode=
texture2D
(
samplerVideo, uvRefracted).rgb; :
uvRefracted=
mix
(
uv, uvRefracted, smoothstep
(
0
.,1
.,isInsideFace));
Nous mettons smoothstep(0.,1.,isInsideFace) au lieu de isInsideFace directement afin d'éviter les discontinuités tangentielles des lignes de code. Ça rajoute un double easing.
Puis pour supprimer les effets de bordure particulièrement disgracieux liés aux différences de luminosité à la périphérie du masque, nous remplaçons : vec3
finalColor=
colorWebcam+
colorLineCode; par :
vec3
finalColor=
colorWebcam*
isInsideFace+
colorLineCode;
III. En conclusion▲
J'espère que ce tutoriel vous a plu et vous a donné l'envie de réaliser vos propres filtres faciaux. THREE.js ou la programmation GLSL sont l'objet de livres entiers et nous n'avons eu que le temps de les survoler. Heureusement, les ressources documentaires et les tutoriels abondent en ligne. Voici quelques liens si vous souhaitez plonger plus profondément dans la Matrix :
- WebGL Academy : 35 tutoriels interactifs sur WebGL et Three.JS ;
- Shader toy : le royaume du GLSL ;
- Site officiel de THREE.js : la doc est très complète et les exemples fournis permettent de vite démarrer un projet ;
- Conférences en français autour du WebGL et de la programmation 3D @WebGLParis : ressources intéressantes dans 'Editions précédentes' ;
- Livre : WebGL - Guide de programmation d'application web 3D ;
- Site officiel de Jeeliz.