Une webcam et un shader sont dans THREE.js

par gsavin

Récupérer le flux vidéo

Il est possible de connecter un élément HTML video à votre Webcam. Rassurez-vous, ceci n’est possible qu’une fois avoir donné l’autorisation à votre navigateur de le faire. Cette fonctionnalité repose sur l’API Media Stream et n’est pas supporté par tous les navigateurs (le programme qui suit a été testé sur des versions récentes de Firefox et Chromium).

La première chose à faire est d’insérer un élement video dans votre page html qui servira de point d’ancrage au flux provenant de la webcam :

<!doctype html>
<html>
  ...
  <body>
    <video/>
    <script type="text/javascript" src="./app.js"></script>
  </body>
</html>

On note ici l’inclusion du script app.js qui va contenir notre programme.

La récupération du flux de la webcam passe par la méthode getUserMedia() du navigateur. Elle consiste à demander l’autorisation à l’utilisateur d’utiliser le flux vidéo et/ou audio de la caméra. Une fois l’accord obtenu, une fonction que nous avons au préalable définie et appelée avec un objet de type MediaStream.

La méthode getUserMedia peut être préfixée. Pour éviter la casse, on peut redéfinir le nom de la méthode au début de notre code :

navigator.getUserMedia = (
  navigator.getUserMedia        ||
  navigator.webkitGetUserMedia  ||
  navigator.mozGetUserMedia     ||
  navigator.msGetUserMedia
);

La suite consiste à attacher le flux à notre objet video via la méthode window.URL.createObjectURL(). On en profite au passage pour redimensionner correctement la vidéo. L’exemple complet (les Ancrage * seront utilisés dans la suite pour compléter le code):

'use strict';

(function() {
  navigator.getUserMedia = (
    navigator.getUserMedia        ||
    navigator.webkitGetUserMedia  ||
    navigator.mozGetUserMedia     ||
    navigator.msGetUserMedia
  );

  var video     = document.querySelector('video')
    , width     = 640
    , height    = 0
    , streaming = false;

  /* Ancrage A */

  if (navigator.getUserMedia) {
    navigator.getUserMedia(
      {
         video: true,
         audio: false
      },

      //
      // Fonction appelée en cas de réussite
      //
      function(localMediaStream) {
        video.setAttribute('autoplay', 'autoplay');
        video.src = window.URL.createObjectURL(localMediaStream);

        video.addEventListener('canplay', function(ev) {
          if (!streaming) {
            height = video.videoHeight / (video.videoWidth / width);

            // Régle un bug sur Firefox (voir les sources)
            if (isNaN(height)) {
              height = width / (4/3);
            }

            video.setAttribute('width',    width);
            video.setAttribute('height',   height);

            streaming = true;

            /* Ancrage B */
          }
        }, false);
      },

      //
      // Fonction appelée en cas d'échec
      //
      function(err) {
         console.log("Une erreur est survenue: " + err);
      }
    );
  }
  else {
    console.log('Ce navigateur ne supporte pas la méthode getUserMedia');
  }
});

Filtres

Il est d’ores et déjà possible de personnaliser la vidéo affichée grâce aux filtres CSS. Il suffit s’ajouter un attribut de style à notre balise video en spécifiant quel filtre utiliser. Par exemple pour flouter l’image (pensez à définir les paramètres filter et -webkit-filter pour une meilleure compatibilité):

<video style="filter: blur(6px); -webkit-filter: blur(6px);"/>

Les filtres disponibles sont (des explications complètes sont disponibles sur ce site, avec, entre autres, l’explication des paramètres) :

  • blur(5px);, flou gaussien ;
  • brightness(0.4);, modification de la luminosité ;
  • contrast(200%);, modification du constraste ;
  • drop-shadow(16px 16px 20px blue);, créer une ombre portée ;
  • grayscale(50%);, convertion en niveaux de gris ;
  • hue-rotate(90deg);, rotation de teinte ;
  • invert(75%);, invertion des couleurs ;
  • opacity(25%);, modification de l’opacité ;
  • saturate(30%);, modification de la saturation des couleurs ;
  • sepia(60%);, convertion en sépia.

Ce n’est cependant pas tout car il est possible de définir un filtre SVG (directement dans le document, ou bien dans un fichier à part) et de l’utiliser comme filtre CSS. Par exemple je peux créer un fichier mon-filtre.svg dont le contenu serait :

<svg style="position: absolute; top: -99999px" xmlns="http://www.w3.org/2000/svg">
  <filter id="blur" x="-5%" y="-5%" width="110%" height="110%">
    <feGaussianBlur in="SourceGraphic" stdDeviation="5"/>
  </filter>
</svg>

Il suffit ensuite d’appliquer le filtre à mon élément HTML en définissant le style filter: url(mon-filtre.svg#blur);.

Cette technique de filtre à le mérite de la simplicité en terme de mise en place. On souhaite cependant pousser les choses encore plus loin en utilisant des shaders. En théorie, une valeur custom existe pour la propriété CSS filter, mais aucun navigateur ne semble l’implanter pour le moment. Dans la suite nous allons voir comment utiliser, grâce à three.js, les shaders sur la vidéo.

Afficher le flux vidéo dans une scène 3d

Nous avons vu qu’il est facile de récupérer le flux de la webcam pour l’afficher dans la page web, et de jouer avec grâce au filtre CSS. Nous allons maintenant voir comment utiliser des filtres plus complexes et surtout personnalisés, grâce aux shaders. Dans un premier temps, il nous faut d’abord rediriger le flux vidéo vers un rendu webgl (qui sera géré par three.js). Puis nous verrons comment appliquer notre shader au rendu.

Le programme proposé ici consiste à plaquer la vidéo sur les faces d’un cube, qu’il sera possible de faire tourner afin d’en savourer toutes les dimensions.

La tête dans le cube

Nous avons besoin d’un élément video afin de faire le lien avec la caméra. Cependant, comme nous allons afficher à le flux dans notre rendu three.js, nous aurons deux vidéos si l’élément video reste présent dans le document. Pour résoudre ce problème, deux solutions s’offrent à nous :

  1. Ajouter le style display: none à l’élément video, ce qui permettra de l’exploiter sans que celui-ci n’apparaisse dans le document ;
  2. Créer un élément video à la volée dans notre code, sans l’ajouter au document.

Afin de plaquer la vidéo sur un objet de la scène 3D, la méthode consiste à créer une texture qui utilisera le flux vidéo. Nous allons donc créer la texture, le matériel utilisé pour le rendu, ainsi que le renderer WebGL. Le code suivant est s’insère au point d’ancrage A du code précédent :

/* Ancrage A */

var texture   = new THREE.Texture( video )
  , material  = new THREE.MeshBasicMaterial( { map: texture, overdraw: true } );

video.style.display = 'none';

texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;

var renderer  = new THREE.WebGLRenderer({ antialias: true })
  , scene     = new THREE.Scene()

renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setClearColor(0x111111);
document.body.appendChild(renderer.domElement);

/* Ancrage C */

Puis, à la suite, on ajoute une caméra et le contrôleur qui nous permettra de manipuler la scène :

var camera    = new THREE.PerspectiveCamera( 45, window.innerWidth / window.innerHeight, 1, 10000 )
  , controls  = new THREE.OrbitControls(camera, renderer.domElement );

camera.position.z = 1000;

controls.enableDamping = true;
controls.dampingFactor = 0.25;
controls.enableZoom = true;

Nous avons maintenant une scène dans laquelle ajouter un objet 3d. Il suffit alors de créer, lorsque la vidéo est prête, un cube aux dimensions de la vidéo et de lui appliquer le matériau que nous avons créer à partir de la texture. Le code suivant est à ajouter au point d’ancrage B du code de la première partie :

/* Ancrage B */

var geometry = new THREE.BoxGeometry(width, height, height);
var mesh = new THREE.Mesh(geometry, material);

scene.add(mesh);

requestAnimationFrame(render);

Il nous reste à définir la méthode render() qui est appelée à la fin du code précédent :

function render() {
  requestAnimationFrame(render);

  if (streaming) {
      texture.needsUpdate = true;
  }

  controls.update();
  renderer.render(scene, camera);
}

En complément, il est possible de gérer le redimensionnement de la fenêtre grâce à l’événement resize de l’objet window :

window.addEventListener( 'resize', onWindowResize, false );

function onWindowResize() {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize( window.innerWidth, window.innerHeight );
}

Il ne faut pas oublier d’inclure dans le fichier html la bibliothèque three.js ainsi que les modules optionnels utilisés :

<!doctype html>
<html lang="en">
  <head>
  	<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
  	<title>Webcam on browser</title>
  </head>
  <body style="margin: 0px; padding: 0px; overflow: hidden;">
   <script type="text/javascript" src="./three.min.js"></script>
   <script type="text/javascript" src="./OrbitControls.js"></script>

   <script type="text/javascript" src="./app.js"></script>
  </body>
</html>

Et voilà ! Vous pouvez joyeusement admirer votre visage (ou du moins ce qui se trouve devant votre caméra) sur le cube qui s’affiche dans navigateur.

Utilisation de shaders…

La dernière étape de ce projet consiste à utiliser des shaders pour altérer la vidéo. Pour l’exemple, nous utiliserons un shader pré-existant dans la distribution de three.js (dans le dossier three.js/examples/js/shaders), mais vous avez la possibilité de définir vos propres shaders.

… grâce au ShaderMaterial

Nous allons remplacer la ligne suivante :

material  = new THREE.MeshBasicMaterial( { map: texture, overdraw: true } );

pour remplacer le materiau utiliser par un autre permettant d’appliquer un shader à la texture. Nous allons utiliser le DotScreenShader disponible dans les fichiers d’exemple de three.js. Il nous suffit de remplacer la ligne précédente par :

material = new THREE.ShaderMaterial(THREE.DotScreenShader);
material.uniforms.tDiffuse.value = texture;

… grâce au compositeur

Il s’agit d’une petite modification de notre code afin d’utiliser un composeur qui nous permettra d’ajouter un post-traitement à notre rendu. Contrairement à la solution précédente, le shader s’appliquera à l’ensemble de la scène 3d et non plus seulement à la vidéo.

Pour cela, il est nécessaire de créer le compositeur en insérant le code suivant au point d’ancrage C :

var composer  = new THREE.EffectComposer(renderer)
  , effect    = new THREE.ShaderPass(THREE.DotScreenShader);

composer.addPass(new THREE.RenderPass(scene, camera));

effect.uniforms['scale'].value = 1.4;
effect.renderToScreen = true;
composer.addPass(effect);

Le composer fonctionne avec des passes, chacune correspondant à un traitement à effectuer sur la surface de rendu. Une première passe de rendu permet d’effectuer le rendu à proprement parler. Puis, on ajoute une passe de post-traitement qui utilisera un shader afin d’altérer le rendu des passes précédentes. DotScreenShader est un des shaders prédéfinis dans three.js qui segmente le rendu en points noir et blanc.

Il faut modifier notre méthode de rendu afin de faire le rendu du composer et non plus du renderer :

function render() {
  requestAnimationFrame(render);

  if (streaming) {
      texture.needsUpdate = true;
  }

  controls.update();
  composer.render();
}

De nouveaux modules three.js doivent être inclut dans le fichier html :

<!doctype html>
<html lang="en">
  <head>
  	<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
  	<title>Webcam on browser</title>
  </head>
  <body style="margin: 0px; padding: 0px; overflow: hidden;">
   <script type="text/javascript" src="./three.min.js"></script>
   <script type="text/javascript" src="./OrbitControls.js"></script>
   <script type="text/javascript" src="./shaders/CopyShader.js"></script>
   <script type="text/javascript" src="./shaders/DotScreenShader.js"></script>
   <script type="text/javascript" src="./postprocessing/EffectComposer.js"></script>
   <script type="text/javascript" src="./postprocessing/RenderPass.js"></script>
   <script type="text/javascript" src="./postprocessing/ShaderPass.js"></script>
   <script type="text/javascript" src="./postprocessing/MaskPass.js"></script>

   <script type="text/javascript" src="./app.js"></script>
  </body>
</html>

Écrire son propre shader

Avant de nous lancer dans cette grande aventure, jetons un coup d’œil sur le code de DotScreenShader qu’on vient d’utiliser. Euh ?!? Pas de panique ! Nous allons essayer de comprendre les grandes lignes de fonctionnement de tout ça.

Tout d’abord, quelques mots sur le shaders. Ce sont des petits programmes qui sont exécutés aux différentes étapes du pipeline de rendu d’OpenGL. Ces programmes sont écrits dans un langage appelé GLSL dont la syntaxe ressemble à celle du C et qui est doté de beaucoup de fonctionnalités de manipulation de vecteurs et de matrices. Nous allons nous intéresser principalement au fragment shader. Il intervient vers la fin du pipeline et pour faire simple, on peut dire que son rôle est de déterminer la couleur de chaque pixel pour le rendu final sur l’écran. Il est important de noter que le même shader est exécuté en parallèle par les centaines (voire les milliers pour les plus chanceux) petites unités de calcul de votre GPU, mais chaque unité a ses propres données. Ainsi les couleurs de plein de pixels sont calculés simultanément, ce qui rend possible le rendu en temps réel.

Comme on a pu le constater, un shader en three.js ressemble à ça :

THREE.MyShader = {
    uniforms: {
        "truc": { value: 42.0 }
        // ...
    },
    vertexShader: "code source du vertex shader",
    fragmentShader: "code source du fragment shader"
};

Les uniforms sont une sorte de paramètres des shaders. On peut les utiliser pour changer le comportement des shaders. Leur nom vient du fait qu’ils sont communs pour toutes les instances du shader contrairement aux varying qui sont des données propres à chaque instance. On peut changer les uniforms à la volée depuis notre code javascript. L’instruction

effect.uniforms['scale'].value = 1.4;

dans l’exemple plus haut faisait exactement ça. À noter également que le code des shaders est fourni sous forme de chaîne de caractères. Ce code sera compilé est chargé sur le GPU.

I see big pixels

Nous allons commencer par un shader qui va pixeliser l’image de la webcam. Pour obtenir cet effet, nous allons tracer une grille imaginaire de petits rectangles (une sorte de macro-pixels) sur la texture (dans notre cas la vidéo). Nous allons récupérer la couleur dans le centre de chaque rectangle et nous allons colorier tout le rectangle de cette couleur.

THREE.PixelateShader = {
    uniforms: {
        "tDiffuse": { value: null },
        "pixels": { value: new THREE.Vector2(64, 48) }
    },
    vertexShader: "", // TODO
    fragmentShader: "" // TODO
};

Le premier uniform tDiffuse est notre texture. Le deuxième pixels est un vecteur de 2 composantes qui détermine le nombre de macro-pixels sur chaque axe.

Occupons nous maintenant du vertex shader.

varying vec2 vUv;

void main() {
    vUv = uv;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

La variable vUv et la première instruction de main nous servent juste à sauvegarder les coordonnées UV du sommet et de les transmettre au fragment shader qui n’a pas d’accès direct à ces coordonnées. Les coordonnées UV sont des nombres entre 0 et 1 qui permettent de situer le sommet dans la texture. La deuxième instruction de main effectue la transformation standard pour calculer la position du sommet.

On est habitué de nous voir dans l’image de la webcam comme dans un miroir. Si vous préférez vous voir comme ça, vous pouvez d’ores et déjà faire une transformation miroir autour de l’axe vertical passant par le centre de la texture en remplaçant la première instruction par

vUv = vec2(1.0 - uv.x, uv.y);

mais ce n’est pas forcement une bonne idée si on veut réutiliser notre shader pour autre chose que des images venant de la webcam.

N’oublions pas que ce code doit figurer sous forme de chaîne de caractères comme valeur de vertexShader. Une façon de le rendre un peu lisible est de faire un tableau dont les éléments sont les lignes et d’utiliser join('\n') à la fin comme c’est fait ici.

Et voici le fragment shader :

uniform sampler2D tDiffuse;
uniform vec2 pixels;
varying vec2 vUv;

void main() {
    vec2 center = (floor(vUv * pixels) + 0.5) / pixels;
    vec4 color = texture2D(tDiffuse, center);
    gl_FragColor = color;

}

On commence par déclarer les deux uniforms tDiffuse et pixels et le varying vUv qui va contenir les coordonnées UV sauvegardées par le vertex shader. Dans main on calcule le centre du rectangle, on récupère la couleur de ce centre et on colorie le fragment de cette couleur.

Chapeau l’artiste !

Nous allons modifier notre shader afin de rendre hommage au style du graphiste Karel Martens avec lequel on collabore sur le projet Cabanes 2K17. Nous allons partir de la même grille de macro-pixels, mais au lieu de faire des rectangles monochromes, nous allons dessiner une bande verticale de couleur dans le centre du rectangle, entourée de gris des deux cotés. Pour déterminer la couleur et la largeur de la bande, on va changer la représentation de la couleur et utiliser HSV à la place de RGB. La couleur de la bande va avoir la même teinte que le centre, mais saturation et valeur maximales. La largeur de la bande sera déterminé par la saturation du centre (une saturation maximale correspondra à une bande qui remplit tout le rectangle). La valeur du centre déterminera le niveau de gris de des deux cotés de la bande allant de noir pour valeur 0 jusqu’à blanc pour valeur 1. En plus on va discrétiser ces paramètres et utiliser un petit nombre de couleurs, largeurs et niveaux de gris différents. On ajoute à notre shader un paramètre cwg qui va déterminer le nombre de couleurs, largeurs et niveaux de gris :

THREE.MartensShader = {
    uniforms: {
        "tDiffuse": { value: null },
        "pixels": { value: new THREE.Vector2(64, 48) },
        "cwg": { value: new THREE.Vector3(12, 5, 4) }
    }
    // ...
}

Le vertex shader ne change pas. Et voici le fragment shader modifié :

vec3 rgb2hsv(vec3 c) {
    vec4 K = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0);
    vec4 p = c.g < c.b ? vec4(c.bg, K.wz) : vec4(c.gb, K.xy);
    vec4 q = c.r < p.x ? vec4(p.xyw, c.r) : vec4(c.r, p.yzx);
    float d = q.x - min(q.w, q.y);
    float e = 1.0e-10;
    return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x);
}

vec3 hsv2rgb(vec3 c) {
    vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
    vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
    return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}

uniform sampler2D tDiffuse;
uniform vec2 pixels;
uniform vec3 cwg;
varying vec2 vUv;

void main() {
    vec2 center = (floor(vUv * pixels) + 0.5) / pixels;
    vec4 color = texture2D(tDiffuse, center);
    vec3 hsv = floor(rgb2hsv(color.rgb) * cwg + 0.5) / cwg;
    if (abs(vUv.x - center.x) < hsv.y / pixels.x / 2.0) {
        gl_FragColor = vec4(hsv2rgb(vec3(hsv.x, 1, 1)), color.a);
    } else {
        gl_FragColor = vec4(hsv.z, hsv.z, hsv.z, color.a);
    }
}

On ne commentera pas en détail les fonctions de conversion entre RGB et HSV trouvées ici. On se contentera de dire qu’elles utilisent des formules de conversion standard optimisées pour GPU. La fonction main commence comme dans notre shader précédent. Ensuite on convertie la couleur du centre en HSV et on discrétise en utilisant cwg. À la fin on mesure la distance horizontale entre le fragment et le centre du rectangle pour déterminer si le fragment est dans la bande. Si c’est le cas, on le colorie en utilisant la même teinte et saturation et valeur maximales. Sinon, on le colorie en gris dont le niveau est déterminé par la valeur.

Démonstration

Et voici la récompense pour les lecteurs les plus assidus qui sont arrivés jusqu’à là. Vous pouvez essayer les différents versions du programme en cliquant sur ces liens:

Sources

Documents ou sites ayant permis de comprendre les mécanismes sous-jascents à la rédaction de ce document.

Nous contacter

Vous pouvez nous contacter à cette adresse ou utiliser le formulaire ci-dessous.