Développer des Expériences Immersives : Web AR et Web VR avec A-Frame et Three.js
Développer des Expériences Immersives : Web AR et Web VR avec A-Frame et Three.js

Ajout d'Interactivité et Gestion des Événements dans les Expériences Web AR/VR

Introduction : Donner Vie à l'Immersif

Dans le monde des expériences immersives, qu'il s'agisse de réalité augmentée (AR) ou de réalité virtuelle (VR) sur le web, la simple observation d'une scène, même magnifiquement rendue, ne suffit pas à captiver pleinement l'utilisateur. Pour transformer une "expérience" en une "interaction", il est crucial d'ajouter des mécanismes permettant à l'utilisateur d'agir sur l'environnement et d'en recevoir un retour. C'est là qu'interviennent l'interactivité et la gestion des événements.

Imaginez une exposition d'art virtuelle où vous ne pouvez pas interagir avec les œuvres, ou un jeu AR où les objets ne réagissent pas à vos actions. Cela serait fade et peu engageant. L'interactivité est le ciment qui lie l'utilisateur à l'expérience, le transformant d'un simple spectateur en un acteur.

Dans cette leçon, nous allons explorer les techniques et les outils pour intégrer l'interactivité dans nos expériences Web AR et Web VR, en nous appuyant sur les frameworks que nous maîtrisons déjà : A-Frame pour sa simplicité et sa puissance déclarative, et Three.js pour un contrôle plus fin et de bas niveau. Nous couvrirons les concepts fondamentaux de la détection d'interactions, la gestion des événements et la manière d'y réagir de manière significative.

Qu'est-ce que l'Interactivité en AR/VR ?

L'interactivité en AR/VR fait référence à la capacité d'un utilisateur à manipuler des objets virtuels, à naviguer dans l'environnement, à déclencher des actions ou à recevoir des retours en réponse à ses propres actions. Contrairement aux interfaces utilisateur 2D traditionnelles (clic de souris, appui clavier), les interactions en 3D sont souvent plus intuitives et gestuelles, tirant parti de la présence spatiale.

Types d'Interactions Courantes :

  • Sélection (Clicks/Taps) : L'action la plus basique, souvent simulée par un "clic" de souris, un "tap" sur un écran tactile, ou la pression d'un bouton sur un contrôleur VR.
  • Survol (Hover) : Détecter quand le regard, le curseur ou un contrôleur pointe vers un objet, souvent utilisé pour un feedback visuel (mise en évidence).
  • Saisie (Input) : Textuelle ou numérique, bien que moins courante directement en 3D, elle peut être nécessaire.
  • Navigation : Déplacer la caméra, se téléporter, ou se déplacer physiquement dans l'espace.
  • Manipulation : Glisser-déposer, redimensionner, faire pivoter des objets.
  • Interactions Gaze-Based (Basées sur le Regard) : Dans les cas où les contrôleurs ne sont pas disponibles (certains casques VR mobiles, AR sur smartphone sans interaction tactile directe sur l'objet 3D), le regard de l'utilisateur peut être utilisé pour pointer et parfois "cliquer" (via un timer).
  • Interactions avec Contrôleurs VR : Utilisation de gâchettes, joysticks, pavés tactiles des contrôleurs (Oculus Touch, Vive Wand, etc.) pour des actions spécifiques.
  • Interactions Basées sur le Mouvement du Device (AR) : Utilisation des capteurs du smartphone (accéléromètre, gyroscope) pour détecter des gestes ou l'orientation du téléphone.

Au cœur de la détection de ces interactions se trouve le concept de raycasting.

Le Raycasting : Comment ça Marche ?

Le raycasting est une technique utilisée en infographie pour déterminer ce qui est visible ou interagit avec une ligne (un "rayon") émise depuis un point dans une direction donnée.

  1. Origine du Rayon : Le rayon part généralement de la caméra (pour les interactions gaze-based ou souris/touch), ou d'un contrôleur VR.
  2. Direction du Rayon : Il pointe vers l'avant de la caméra, vers la position du curseur sur l'écran (projeté en 3D), ou depuis le contrôleur.
  3. Détection d'Intersection : Le système vérifie si ce rayon "frappe" des objets 3D dans la scène.
  4. Information sur l'Intersection : Si une intersection est détectée, le raycaster renvoie des informations sur l'objet touché, la distance, le point d'impact, etc.

C'est ce mécanisme qui nous permet de savoir quel objet virtuel l'utilisateur est en train de pointer ou de sélectionner.

Gestion des Événements avec A-Frame : Simplicité et Déclaration

A-Frame, grâce à son architecture basée sur les composants et son approche déclarative, simplifie grandement l'ajout d'interactivité. Il encapsule le raycasting et la gestion des événements DOM pour les entités 3D.

Le Système d'Événements d'A-Frame

A-Frame étend le modèle d'événements du DOM aux entités 3D. Cela signifie que vous pouvez attacher des écouteurs d'événements (event listeners) directement aux entités, comme vous le feriez avec des éléments HTML.

Les composants clés pour l'interactivité sont :

  1. raycaster : Ce composant est appliqué à une entité (généralement la caméra ou un contrôleur) et est responsable de l'émission des rayons et de la détection des intersections.
    • Propriétés importantes : objects (sélecteur CSS pour spécifier quels objets peuvent être détectés), interval (fréquence de l'émission des rayons).
  2. cursor : Ce composant est souvent ajouté à l'entité qui possède le raycaster (par exemple, la caméra). Il fournit un feedback visuel pour le raycaster (un point, un anneau) et peut simuler des clics basés sur le regard (fuse: true). Il émet également des événements spécifiques quand il interagit avec des objets.

Quand un rayon émis par le raycaster interagit avec une entité, celle-ci émet divers événements DOM :

  • click : quand l'entité est cliquée (ou tapée, ou sélectionnée par le cursor via fuse).
  • mouseenter : quand le curseur/rayon entre en collision avec l'entité.
  • mouseleave : quand le curseur/rayon quitte l'entité.
  • raycaster-intersected : événement plus bas niveau déclenché lorsque le raycaster a un objet en ligne de mire.
  • raycaster-intersected-cleared : lorsque l'objet n'est plus en ligne de mire.

Exemple Pratique : Un Cube Interactif avec A-Frame

Nous allons créer une scène avec un cube qui change de couleur lorsqu'on le clique et qui se met en évidence lorsqu'on le survole.

<!DOCTYPE html>
<html>
<head>
    <title>Cube Interactif A-Frame</title>
    <script src="https://aframe.io/releases/1.5.0/aframe.min.js"></script>
    <script>
        // Composant A-Frame personnalisé pour la gestion des événements
        AFRAME.registerComponent('interactive-cube', {
            init: function () {
                var el = this.el; // L'entité sur laquelle le composant est attaché
                var defaultColor = el.getAttribute('material').color;
                var hoverColor = '#FF00FF'; // Magenta
                var clickColor = '#00FFFF'; // Cyan

                // Écouteur pour le survol (mouseenter)
                el.addEventListener('mouseenter', function () {
                    el.setAttribute('material', 'color', hoverColor);
                });

                // Écouteur pour la sortie de survol (mouseleave)
                el.addEventListener('mouseleave', function () {
                    el.setAttribute('material', 'color', defaultColor);
                });

                // Écouteur pour le clic
                el.addEventListener('click', function () {
                    // Change la couleur du cube à une couleur aléatoire au clic
                    var newColor = '#' + Math.floor(Math.random()*16777215).toString(16);
                    el.setAttribute('material', 'color', newColor);
                    // Mettre à jour la couleur par défaut pour qu'elle revienne à la nouvelle couleur
                    defaultColor = newColor;
                });

                console.log('Interactive cube component initialized!');
            }
        });
    </script>
</head>
<body>
    <a-scene background="color: #FAFAFA">
        <!-- Caméra avec curseur et raycaster pour les interactions -->
        <a-entity camera look-controls wasd-controls position="0 1.6 0">
            <a-cursor raycaster="objects: .collidable; far: 20"></a-cursor>
        </a-entity>

        <!-- Un cube avec notre composant interactif -->
        <a-box class="collidable" interactive-cube position="0 1.5 -3" rotation="0 45 0" color="#4CC3D9" shadow></a-box>

        <!-- Un deuxième cube qui ne réagit pas -->
        <a-box position="2 1.5 -3" rotation="0 -45 0" color="#FF0000" shadow></a-box>

        <!-- Lumière ambiante et directionnelle -->
        <a-entity light="type: ambient; color: #BBB"></a-entity>
        <a-entity light="type: directional; color: #FFF; intensity: 0.6" position="-0.5 1 1"></a-entity>

        <!-- Sol -->
        <a-plane position="0 0 -4" rotation="-90 0 0" width="10" height="10" color="#7BC8A4" shadow></a-plane>
    </a-scene>
</body>
</html>

Explication du Code A-Frame :

  1. <a-scene> : Le conteneur principal de notre expérience 3D.
  2. <a-entity camera> : Notre caméra, à laquelle nous ajoutons les contrôles de base (look-controls, wasd-controls) pour la navigation.
  3. <a-cursor> : C'est le composant le plus important pour l'interactivité.
    • Il est enfant de la caméra, donc il émet un rayon depuis la perspective de l'utilisateur.
    • raycaster="objects: .collidable; far: 20" : Configure le raycaster.
      • objects: .collidable : Indique que le raycaster ne doit détecter les intersections qu'avec les entités ayant la classe collidable. C'est une excellente pratique pour optimiser les performances en limitant les objets à tester.
      • far: 20 : Le rayon ne détectera pas les objets au-delà de 20 unités.
    • Le cursor affiche également un petit cercle au centre de l'écran, changeant de couleur au survol.
  4. <a-box class="collidable" interactive-cube ...> : Notre cube cible.
    • La classe collidable le rend détectable par le raycaster de la caméra.
    • interactive-cube est notre composant JavaScript personnalisé.
  5. AFRAME.registerComponent('interactive-cube', {...}) :
    • Nous créons un composant A-Frame qui encapsule la logique d'interactivité.
    • La fonction init() est appelée lorsque le composant est attaché à une entité et initialisé.
    • this.el fait référence à l'entité A-Frame sur laquelle le composant est attaché (ici, notre <a-box>).
    • el.addEventListener('mouseenter', function () { ... }); : Attache un écouteur pour l'événement mouseenter. Lorsque le raycaster du curseur entre en collision avec le cube, cet événement est déclenché.
    • el.setAttribute('material', 'color', hoverColor); : Utilise la méthode setAttribute d'A-Frame pour changer dynamiquement la couleur du matériau du cube.
    • De manière similaire, des écouteurs sont définis pour mouseleave (pour restaurer la couleur par défaut) et click (pour changer la couleur aléatoirement).
    • Notez que le click est déclenché par l'action de "clic" (bouton de souris ou tap écran) ou si fuse: true est activé sur le cursor (ce qui déclencherait un clic après un certain temps de survol).

Avec A-Frame, la gestion des événements est donc très similaire à la manipulation du DOM HTML, ce qui rend l'apprentissage et le développement d'expériences interactives beaucoup plus rapides.

Gestion des Événements avec Three.js : Contrôle de Bas Niveau

Lorsque vous utilisez Three.js directement, vous avez un contrôle total sur chaque aspect de votre scène, y compris la gestion des événements. Cela signifie plus de code, mais aussi une flexibilité maximale. Le raycasting est le mécanisme principal pour la détection d'interactions.

Le Principe de Raycasting avec Three.js

  1. THREE.Raycaster : C'est la classe fondamentale. Vous créez une instance et lui passez l'origine du rayon et sa direction.
  2. Coordonnées de Souris/Touch : Vous devez capturer les coordonnées (x, y) de l'événement de la souris ou du toucher sur le canvas de rendu. Ces coordonnées doivent ensuite être normalisées pour correspondre à l'espace de coordonnées de Three.js (-1 à 1 pour x et y).
  3. Mise à Jour du Raycaster : À chaque événement de souris/touch, ou dans la boucle de rendu pour le raycasting basé sur la caméra/le regard, vous mettez à jour l'origine et la direction du raycaster.
    • Pour la souris/touch : raycaster.setFromCamera(mouse, camera);mouse est un THREE.Vector2 normalisé.
    • Pour le regard (raycasting depuis la caméra) : raycaster.set(camera.position, camera.getWorldDirection(new THREE.Vector3()));
  4. Détection d'Intersections : raycaster.intersectObjects(objects, recursive);
    • objects : Un tableau d'objets Three.js à tester.
    • recursive : Un booléen indiquant si les enfants des objets doivent être testés.

Exemple Pratique : Un Objet Three.js Réactif

Créons une scène Three.js avec un cube qui change de couleur au clic et qui est mis en évidence au survol.

<!DOCTYPE html>
<html>
<head>
    <title>Objet Interactif Three.js</title>
    <style>
        body { margin: 0; overflow: hidden; }
        canvas { display: block; }
    </style>
</head>
<body>
    <script type="module">
        import * as THREE from 'https://unpkg.com/three/build/three.module.js';

        let scene, camera, renderer, cube;
        let raycaster;
        let mouse = new THREE.Vector2();
        let intersectedObject;
        let defaultColor = new THREE.Color(0x00ff00); // Vert
        let hoverColor = new THREE.Color(0xff00ff); // Magenta
        let clock = new THREE.Clock();

        function init() {
            // Scène
            scene = new THREE.Scene();
            scene.background = new THREE.Color(0xFAFAFA);

            // Caméra
            camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
            camera.position.z = 5;

            // Rendu
            renderer = new THREE.WebGLRenderer({ antialias: true });
            renderer.setSize(window.innerWidth, window.innerHeight);
            document.body.appendChild(renderer.domElement);

            // Cube interactif
            const geometry = new THREE.BoxGeometry(1, 1, 1);
            const material = new THREE.MeshBasicMaterial({ color: defaultColor });
            cube = new THREE.Mesh(geometry, material);
            scene.add(cube);

            // Lumières
            const ambientLight = new THREE.AmbientLight(0x404040); // Soft white light
            scene.add(ambientLight);
            const directionalLight = new THREE.DirectionalLight(0xffffff, 0.5);
            directionalLight.position.set(1, 1, 1).normalize();
            scene.add(directionalLight);

            // Raycaster
            raycaster = new THREE.Raycaster();

            // Gestion des événements
            window.addEventListener('mousemove', onMouseMove, false);
            window.addEventListener('click', onClick, false);
            window.addEventListener('resize', onWindowResize, false);
        }

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

        function onMouseMove(event) {
            // Calcul des coordonnées de la souris normalisées (-1 à +1)
            mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
            mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
        }

        function onClick(event) {
            // Seulement si un objet est actuellement survolé, le "cliquer"
            if (intersectedObject) {
                // Change la couleur du cube à une couleur aléatoire
                const newColor = new THREE.Color(Math.random(), Math.random(), Math.random());
                intersectedObject.material.color.copy(newColor);
                defaultColor.copy(newColor); // Met à jour la couleur par défaut
            }
        }

        function animate() {
            requestAnimationFrame(animate);

            const delta = clock.getDelta();
            // Optional: Rotate the cube for visual interest
            cube.rotation.x += 0.5 * delta;
            cube.rotation.y += 0.5 * delta;

            // Mettre à jour le raycaster avec les coordonnées de la souris
            raycaster.setFromCamera(mouse, camera);

            // Calculer les objets qui intersectent le rayon
            const intersects = raycaster.intersectObjects(scene.children, true);

            if (intersects.length > 0) {
                if (intersectedObject !== intersects[0].object) {
                    // Si un nouvel objet est survolé
                    if (intersectedObject) {
                        // Restaurer la couleur de l'ancien objet survolé
                        intersectedObject.material.color.copy(defaultColor);
                    }
                    intersectedObject = intersects[0].object;
                    // Mettre en évidence le nouvel objet survolé
                    intersectedObject.material.color.copy(hoverColor);
                }
            } else {
                // Si aucun objet n'est survolé
                if (intersectedObject) {
                    // Restaurer la couleur de l'objet qui n'est plus survolé
                    intersectedObject.material.color.copy(defaultColor);
                }
                intersectedObject = null;
            }

            renderer.render(scene, camera);
        }

        init();
        animate();
    </script>
</body>
</html>

Explication du Code Three.js :

  1. Import de Three.js : Nous utilisons l'import ES module directement depuis un CDN.
  2. Variables Globales : scene, camera, renderer, cube (notre objet interactif), raycaster, mouse (un THREE.Vector2 pour stocker les coordonnées normalisées de la souris), intersectedObject (pour garder une référence à l'objet actuellement survolé), et des couleurs.
  3. init() :
    • Configure la scène, la caméra et le rendu Three.js.
    • Crée un simple cube (THREE.Mesh) et l'ajoute à la scène.
    • Initialise raycaster = new THREE.Raycaster();.
    • Attache les écouteurs d'événements DOM standard (mousemove, click, resize) à la fenêtre.
  4. onMouseMove(event) :
    • Cette fonction est appelée chaque fois que la souris bouge.
    • Elle calcule les coordonnées normalisées de la souris :
      • event.clientX et event.clientY sont les coordonnées en pixels.
      • (event.clientX / window.innerWidth) * 2 - 1 : projette la coordonnée X de [0, innerWidth] à [-1, 1].
      • -(event.clientY / window.innerHeight) * 2 + 1 : projette la coordonnée Y de [0, innerHeight] (top vers bas) à [1, -1] (bas vers haut pour Three.js).
    • Les stocke dans le vecteur mouse.
  5. onClick(event) :
    • Est déclenchée au clic de souris.
    • Si un objet est actuellement survolé (intersectedObject n'est pas nul), elle change sa couleur à une couleur aléatoire.
  6. animate() : La boucle de rendu principale, appelée via requestAnimationFrame.
    • cube.rotation.x += 0.01; cube.rotation.y += 0.01; : Fait tourner le cube pour l'intérêt visuel.
    • raycaster.setFromCamera(mouse, camera); : C'est ici que le raycaster est mis à jour en utilisant les dernières coordonnées de la souris et la position/orientation de la caméra.
    • const intersects = raycaster.intersectObjects(scene.children, true); : Exécute le raycasting.
      • scene.children : C'est le tableau de tous les objets de haut niveau dans la scène. Pour une meilleure performance, il est recommandé de ne passer que les objets interactifs spécifiques.
      • true : Indique de vérifier aussi les enfants des objets.
    • Logique de survol :
      • Si intersects.length > 0, un ou plusieurs objets sont survolés. Nous prenons le premier (intersects[0].object) car c'est le plus proche.
      • Si cet objet est différent de intersectedObject (nouvel objet survolé), nous restaurons la couleur de l'ancien objet (s'il y en avait un) et appliquons la couleur de survol au nouveau.
      • Si intersects.length == 0, aucun objet n'est survolé. Si intersectedObject n'est pas nul, nous restaurons sa couleur par défaut et le réinitialisons à null.
    • renderer.render(scene, camera); : Rend la scène.

Avec Three.js, vous gérez explicitement le raycasting et la logique de détection des intersections, ce qui offre une grande flexibilité mais exige également plus de code et une meilleure compréhension des mathématiques 3D sous-jacentes.

Considérations Spécifiques à l'AR/VR

Interactions Gaze-Based (Regard)

  • A-Frame : Le composant a-cursor prend en charge le "gaze-based click" via sa propriété fuse: true. L'utilisateur maintient son regard sur un objet pendant une courte période (par défaut 1500 ms) pour le "cliquer".
  • Three.js : Il faut implémenter cette logique manuellement. Le raycaster serait émis depuis la position de la caméra dans sa direction de regard actuelle. Vous devriez gérer un timer JavaScript pour détecter la durée du survol sur un objet.

Contrôleurs VR

  • A-Frame : Fournit des composants dédiés comme hand-controls, laser-controls, et tracked-controls qui simplifient l'intégration des contrôleurs VR. Ces composants créent des entités représentatives des contrôleurs et émettent des événements (comme triggerdown, gripup) que vous pouvez écouter. Le laser-controls inclut un raycaster pour pointer et interagir avec les objets à distance.
  • Three.js : Nécessite une interaction plus directe avec l'API WebXR Device. Vous devrez :
    • Détecter la disponibilité de contrôleurs.
    • Obtenir la pose et l'état des boutons des contrôleurs (XRInputSource).
    • Créer un raycaster qui suit la pose du contrôleur pour détecter les interactions.
    • Gérer les événements des boutons du contrôleur (par exemple, selectstart, selectend). C'est un sujet plus avancé qui demande une compréhension approfondie de l'API WebXR.

Performance

  • Le raycasting peut être coûteux en calcul, surtout si vous testez un grand nombre d'objets ou si vous le faites très fréquemment.
  • Optimisation :
    • Limitez le nombre d'objets passés à raycaster.intersectObjects (comme dans l'exemple A-Frame avec objects: .collidable).
    • Utilisez des intervalles (raycaster.interval) en A-Frame, ou ne mettez à jour le raycaster qu'en cas de besoin dans Three.js.
    • Considérez des structures de données spatiales comme les arbres octaux ou quadtrees pour accélérer les requêtes d'intersection dans des scènes très denses (pour Three.js).

Bonnes Pratiques

  • Feedback Visuel : Toujours fournir un retour visuel clair lorsque l'utilisateur interagit avec un objet (changement de couleur, surbrillance, échelle).
  • Feedback Sonore : Ajoutez des effets sonores pour renforcer les actions de l'utilisateur.
  • Zones d'Interaction Claires : Assurez-vous que les objets interactifs sont facilement identifiables et que leurs zones de clic/survol sont suffisamment grandes.
  • Gestion de l'État : Utilisez des variables d'état pour suivre quel objet est survolé ou sélectionné afin d'éviter les bugs et de faciliter la logique (comme intersectedObject dans l'exemple Three.js).
  • Abstraction (A-Frame) : Créez des composants A-Frame réutilisables pour encapsuler des comportements interactifs complexes.
  • Délégation d'Événements (Three.js) : Pour des scènes complexes, envisagez une approche où un gestionnaire global écoute les événements de la souris et utilise le raycaster pour déterminer la cible, plutôt que d'attacher des écouteurs à chaque objet.

Conclusion

L'interactivité est l'âme des expériences immersives. Sans elle, les mondes virtuels et augmentés restent des coquilles vides, des dioramas statiques. En apprenant à gérer les événements et à utiliser le raycasting, vous donnez à vos utilisateurs le pouvoir d'agir, de découvrir et de s'engager avec vos créations.

Nous avons vu comment A-Frame simplifie cette tâche grâce à son système déclaratif de composants raycaster et cursor, permettant d'ajouter facilement des interactions de clic et de survol. En parallèle, nous avons exploré comment Three.js offre un contrôle plus granulaire sur le raycasting, exigeant plus de code mais ouvrant la porte à des interactions personnalisées et complexes.

Que vous optiez pour la rapidité de développement d'A-Frame ou la flexibilité de Three.js, la maîtrise de l'interactivité transformera vos scènes 3D en des univers réactifs et captivants. Continuez à expérimenter avec différents types d'interactions, et n'oubliez jamais que l'objectif est de rendre l'utilisateur actif et engagé au cœur de votre expérience immersive.