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

Exploration de Three.js : Personnalisation et Contrôle Avancé des Scènes Immersives

Introduction

Bienvenue dans cette leçon dédiée à l'exploration approfondie de Three.js ! Dans le cadre de notre cours "Développer des Expériences Immersives : Web AR et Web VR avec A-Frame et Three.js", nous avons déjà abordé les bases de la création de scènes 3D interactives. Aujourd'hui, nous allons pousser les limites en nous concentrant sur la personnalisation et le contrôle avancé de ces scènes.

Three.js est une bibliothèque JavaScript incroyablement puissante qui nous permet de rendre des graphiques 3D directement dans le navigateur. Si A-Frame simplifie grandement le développement d'expériences Web XR en offrant un framework déclaratif, comprendre et maîtriser Three.js nous ouvre la porte à une flexibilité inégalée pour :

  • Créer des géométries uniques et complexes.
  • Concevoir des matériaux et des effets visuels sur mesure via des shaders.
  • Implémenter des interactions utilisateur sophistiquées.
  • Optimiser les performances pour des scènes riches.

L'objectif de cette leçon est de vous donner les outils nécessaires pour transcender les primitives et les comportements standards, et bâtir des expériences 3D véritablement uniques et immersives.

Rappel des Fondamentaux de Three.js

Avant de plonger dans les aspects avancés, rappelons brièvement les composants essentiels d'une scène Three.js :

  • Scene : Le conteneur racine pour tous vos objets 3D (modèles, lumières, caméras).
  • Camera : Le point de vue de l'utilisateur sur la scène. Les types courants incluent PerspectiveCamera (pour un rendu réaliste) et OrthographicCamera (pour des vues isométriques ou 2D).
  • Renderer : Le moteur qui prend la scène et la caméra, et les dessine sur un élément <canvas> du navigateur. WebGLRenderer est le plus utilisé.
  • Mesh : Représente un objet 3D. Il est composé d'une Geometry (la forme) et d'un Material (l'apparence).
  • Light : Source de lumière qui illumine la scène et affecte l'apparence des matériaux.

Ces briques de base sont la fondation sur laquelle nous allons construire nos personnalisations avancées.

Personnalisation des Objets et Matériaux

Géométries Personnalisées : Introduction à BufferGeometry

Lorsque les géométries primitives fournies par Three.js (CubeGeometry, SphereGeometry, etc.) ne suffisent plus, BufferGeometry devient votre meilleur allié. BufferGeometry est la base de toutes les géométries dans Three.js et permet de définir des formes 3D en spécifiant directement leurs sommets (vertices), normales (normals) pour l'éclairage, coordonnées UV pour le texturage, et d'autres attributs.

Pourquoi utiliser BufferGeometry ?

  • Performance : Stocke les données des sommets de manière très efficace, optimisé pour le GPU. Indispensable pour les scènes complexes ou les objets avec un grand nombre de polygones.
  • Flexibilité : Permet de créer n'importe quelle forme 3D imaginable, de l'objet le plus simple au plus complexe.
  • Contrôle fin : Vous avez un contrôle total sur chaque aspect de la géométrie.

Création d'une BufferGeometry Simple

Pour créer une BufferGeometry, vous devez définir au minimum les positions des sommets. Les normales et les UVs sont également cruciaux pour un rendu correct.

Considérons la création d'un simple triangle :

// 1. Initialiser une BufferGeometry vide
const customGeometry = new THREE.BufferGeometry();

// 2. Définir les positions des sommets
// Chaque groupe de 3 nombres (x, y, z) représente un sommet
const vertices = new Float32Array([
    -1.0, -1.0,  0.0,  // Sommet 1 (x, y, z)
     1.0, -1.0,  0.0,  // Sommet 2
     0.0,  1.0,  0.0   // Sommet 3
]);

// 3. Ajouter les positions comme un attribut à la géométrie
// 'position' est un nom d'attribut standard
// 3 indique que chaque sommet a 3 composantes (x, y, z)
customGeometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));

// (Optionnel) Définir les normales pour un éclairage correct
// Pour un simple triangle plat, les normales pointent toutes dans la même direction
const normals = new Float32Array([
     0.0,  0.0,  1.0,  // Normale du sommet 1
     0.0,  0.0,  1.0,  // Normale du sommet 2
     0.0,  0.0,  1.0   // Normale du sommet 3
]);
customGeometry.setAttribute('normal', new THREE.BufferAttribute(normals, 3));

// (Optionnel) Définir les UVs pour le texturage
const uvs = new Float32Array([
    0.0, 0.0, // UV du sommet 1
    1.0, 0.0, // UV du sommet 2
    0.5, 1.0  // UV du sommet 3
]);
customGeometry.setAttribute('uv', new THREE.BufferAttribute(uvs, 2));


// 4. Créer un matériau et un maillage
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00, side: THREE.DoubleSide });
// Pour que l'éclairage fonctionne, utilisez MeshStandardMaterial ou MeshPhongMaterial et ajoutez des lumières.
// const material = new THREE.MeshStandardMaterial({ color: 0x00ff00, side: THREE.DoubleSide });
const triangleMesh = new THREE.Mesh(customGeometry, material);

// Ajouter le maillage à la scène
// scene.add(triangleMesh);

Explication du code :

  • Nous commençons par créer une instance de BufferGeometry.
  • Un Float32Array est utilisé pour stocker les coordonnées des sommets. Chaque triplet (x, y, z) représente un point dans l'espace 3D.
  • customGeometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3)) attache ces données à l'attribut position de la géométrie. 3 indique que chaque "item" dans l'array pour cet attribut est composé de 3 valeurs.
  • Les normales et les UVs sont définis de manière similaire. Les normales sont des vecteurs qui indiquent la direction "extérieure" de la surface à chaque sommet, essentiel pour le calcul de l'éclairage. Les UVs sont des coordonnées 2D (u, v) utilisées pour mapper une texture sur la surface de l'objet.
  • Enfin, un Mesh est créé avec cette géométrie personnalisée et un matériau.

Matériaux Avancés et Shaders (GLSL)

Les matériaux par défaut de Three.js (MeshStandardMaterial, MeshPhongMaterial, MeshBasicMaterial, etc.) couvrent un large éventail de besoins. Cependant, pour des effets visuels réellement uniques, des rendus non photoréalistes, ou une optimisation poussée, les shaders sont indispensables.

Les shaders sont de petits programmes écrits en GLSL (OpenGL Shading Language) qui s'exécutent directement sur le GPU. Ils déterminent comment chaque pixel d'un objet est rendu. Il existe deux types principaux :

  • Vertex Shader : Traite les informations de chaque sommet de la géométrie (position, normale, UV). Il est responsable de la transformation des coordonnées 3D de l'espace objet vers l'espace de l'écran, et peut manipuler la position des sommets (ex: ondulations, explosions).
  • Fragment Shader : S'exécute pour chaque "fragment" (qui correspond souvent à un pixel) de la surface de l'objet. Il est responsable de la détermination de la couleur finale du pixel, en tenant compte de la lumière, des textures, des reflets, etc.

Three.js offre deux classes pour travailler avec les shaders :

  • ShaderMaterial : Permet de définir des shaders GLSL personnalisés. Il fournit également des mécanismes pour passer des données (uniforms, attributes) du JavaScript aux shaders.
  • RawShaderMaterial : Une version encore plus basique qui ne fournit aucune fonctionnalité par défaut de Three.js (comme la gestion des matrices de projection/vue). Vous devez tout gérer vous-même, offrant un contrôle maximal mais une complexité accrue.

Concepts Clés des Shaders :

  • Uniforms : Variables globales dont la valeur est la même pour tous les sommets ou fragments d'un objet. Elles sont passées du code JavaScript au shader (ex: couleur globale, temps écoulé, position de la lumière).
  • Attributes : Variables par sommet qui définissent les propriétés de chaque sommet (ex: position, normale, UVs). Elles sont définies dans BufferGeometry.
  • Varyings : Variables utilisées pour passer des données du Vertex Shader au Fragment Shader. Elles sont interpolées sur la surface de l'objet.

Structure d'un ShaderMaterial (Exemple Conceptuel)

Bien qu'un exemple de code GLSL complet soit trop long pour cette introduction, voici comment vous structureriez un ShaderMaterial en JavaScript et les bases du GLSL :

// Définition des uniforms (variables passées de JS au shader)
const uniforms = {
    time: { value: 0.0 },
    color: { value: new THREE.Color(0xff0000) }
};

// Vertex Shader (GLSL)
const vertexShader = `
    uniform float time;
    attribute vec3 position; // Attribut de position des sommets
    // Autres attributs (normal, uv)

    varying vec3 vColor; // Variable passant au fragment shader

    void main() {
        // Manipuler la position des sommets ici
        vec3 transformedPosition = position;
        // Exemple simple d'animation de la position en Y
        transformedPosition.y += sin(transformedPosition.x * 5.0 + time) * 0.1;

        // Calculer la position finale projetée
        gl_Position = projectionMatrix * modelViewMatrix * vec4(transformedPosition, 1.0);

        // Passer une couleur ou autre info au fragment shader
        vColor = transformedPosition * 0.5 + 0.5; // Couleur basée sur la position
    }
`;

// Fragment Shader (GLSL)
const fragmentShader = `
    uniform vec3 color; // Couleur passée du JS
    varying vec3 vColor; // Couleur interpolée du vertex shader

    void main() {
        // Calculer la couleur finale du pixel
        gl_FragColor = vec4(vColor * color, 1.0); // Multiplier les couleurs
    }
`;

// Création du ShaderMaterial
const customShaderMaterial = new THREE.ShaderMaterial({
    uniforms: uniforms,
    vertexShader: vertexShader,
    fragmentShader: fragmentShader,
    transparent: true,
    wireframe: false
});

// Créer un maillage avec cette géométrie et ce matériau
// const planeGeometry = new THREE.PlaneGeometry(5, 5, 10, 10); // Un plan avec des subdivisions
// const customMesh = new THREE.Mesh(planeGeometry, customShaderMaterial);
// scene.add(customMesh);

// Dans votre boucle d'animation, mettez à jour l'uniform 'time'
// uniforms.time.value += 0.01;

Explication du code (conceptuel) :

  • Le vertexShader reçoit la position de chaque sommet et peut la modifier (ici, une simple ondulation en Y basée sur time). Il calcule ensuite la position finale gl_Position et passe vColor au fragmentShader.
  • Le fragmentShader reçoit la vColor interpolée pour chaque fragment et le uniform color global. Il utilise ces informations pour déterminer la gl_FragColor, la couleur finale du pixel.
  • Le ShaderMaterial est instancié avec ces shaders et les uniforms définis en JavaScript. Les uniforms sont des ponts entre votre logique JavaScript et le code GLSL.

Les shaders sont un domaine vaste et complexe, mais leur maîtrise ouvre des possibilités illimitées pour des effets visuels personnalisés qui ne seraient pas réalisables avec les matériaux standards.

Contrôle Avancé de la Scène

Au-delà de l'apparence, le contrôle des interactions et de l'animation est essentiel pour des expériences immersives.

Interactions Utilisateur : Raycasting

Le Raycasting est une technique fondamentale pour détecter les intersections entre un rayon (généralement issu de la caméra et pointant dans la direction de la souris) et les objets de la scène. C'est le mécanisme derrière la sélection d'objets ou les interactions contextuelles.

Comment fonctionne le Raycasting ?

  1. Définir la position de la souris : Convertir les coordonnées de l'écran (pixels) en coordonnées normalisées de l l'appareil (-1 à 1 pour X et Y).
  2. Créer un rayon : Utiliser un THREE.Raycaster pour générer un rayon depuis la caméra, passant par les coordonnées de la souris dans l'espace 3D.
  3. Tester les intersections : Le Raycaster peut ensuite tester ce rayon contre un ensemble d'objets de la scène pour trouver ceux qu'il intersecte.
  4. Traiter les résultats : La méthode intersectObjects retourne un tableau d'objets intersectés, triés par distance.

Exemple de Raycasting pour la Sélection d'Objets

// Initialisation du Raycaster et du vecteur de la souris
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();

// Un objet pour stocker l'objet actuellement survolé
let intersectedObject = null;
let previousColor = null;

// Fonction de gestionnaire d'événement pour le mouvement de la souris
function onMouseMove(event) {
    // Calcul des coordonnées normalisées de la souris (-1 à +1)
    mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
    mouse.y = - (event.clientY / window.innerHeight) * 2 + 1;
}

// Ajouter l'écouteur d'événement
window.addEventListener('mousemove', onMouseMove, false);

// Dans votre boucle d'animation (animate() function) :
function animate() {
    requestAnimationFrame(animate);

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

    // 2. Calculer les intersections avec les objets de la scène
    // Remplacez 'scene.children' par un tableau spécifique d'objets à tester si nécessaire.
    const intersects = raycaster.intersectObjects(scene.children, true); // true pour tester les enfants des objets

    if (intersects.length > 0) {
        // Il y a des intersections
        if (intersectedObject !== intersects[0].object) {
            // Un nouvel objet est survolé

            // Rétablir la couleur de l'objet précédemment survolé
            if (intersectedObject) {
                intersectedObject.material.color.set(previousColor);
            }

            // Stocker le nouvel objet et sa couleur d'origine
            intersectedObject = intersects[0].object;
            previousColor = intersectedObject.material.color.getHex();

            // Mettre en évidence le nouvel objet
            intersectedObject.material.color.set(0xff00ff); // Couleur de surbrillance
        }
    } else {
        // Aucune intersection
        if (intersectedObject) {
            // Rétablir la couleur de l'objet précédemment survolé
            intersectedObject.material.color.set(previousColor);
            intersectedObject = null;
            previousColor = null;
        }
    }

    renderer.render(scene, camera);
}

// N'oubliez pas d'appeler animate() une première fois
// animate();

Explication du code :

  • THREE.Raycaster et THREE.Vector2 sont initialisés une seule fois.
  • La fonction onMouseMove met à jour mouse.x et mouse.y pour qu'ils soient dans l'intervalle [-1, 1], nécessaire pour le raycaster.
  • Dans la boucle d'animation, raycaster.setFromCamera(mouse, camera) met à jour le rayon.
  • raycaster.intersectObjects(scene.children, true) cherche les intersections. true indique de vérifier les enfants des objets (par exemple, si vous avez un groupe d'objets).
  • Si des objets sont trouvés, le premier (le plus proche) est sélectionné. Son matériau est temporairement modifié pour le mettre en évidence. Si aucun objet n'est survolé, la couleur de l'objet précédemment sélectionné est restaurée.

Ce mécanisme est la pierre angulaire de nombreuses interactions utilisateur, comme les menus interactifs en VR, les sélections d'éléments en AR, ou les jeux 3D.

Gestion de l'Animation : requestAnimationFrame et Boucles Personnalisées

L'animation dans Three.js est principalement gérée par la fonction globale requestAnimationFrame(). Elle demande au navigateur d'exécuter une fonction donnée avant le prochain rafraîchissement de l'écran, assurant une animation fluide et une utilisation efficace des ressources.

Boucle d'Animation Standard

let rotationSpeed = 0.01;
// Supposons que 'myObject' est un THREE.Mesh déjà ajouté à la scène.

function animate() {
    requestAnimationFrame(animate); // Demande la prochaine frame

    // Mettre à jour la logique de votre scène ici

    // Exemple : Rotation d'un objet
    if (myObject) {
        myObject.rotation.x += rotationSpeed;
        myObject.rotation.y += rotationSpeed * 0.5;
    }

    // Rendu de la scène
    renderer.render(scene, camera);
}

// Appeler la fonction une première fois pour démarrer l'animation
// animate();

Explication du code :

  • requestAnimationFrame(animate) est la ligne clé. Elle crée une boucle récursive où la fonction animate est appelée à chaque rafraîchissement d'écran.
  • Dans cette boucle, vous placez toute la logique qui doit être mise à jour constamment : les mouvements d'objets, les calculs de physique, la mise à jour des uniforms de shaders, etc.
  • renderer.render(scene, camera) est toujours la dernière étape pour dessiner la scène mise à jour.

Tweening et Animations Complexes

Pour des animations plus sophistiquées (mouvements fluides entre deux états, animations basées sur des courbes), des bibliothèques de tweening comme GSAP ou TWEEN.js sont très utiles. Elles simplifient la gestion des interpolations de valeurs au fil du temps.

Gestion des Événements et Contrôles de Caméra

  • Événements Clavier/Souris : Vous pouvez attacher des écouteurs d'événements (addEventListener) pour capturer les interactions classiques (clics, touches, défilement de la molette) et les traduire en mouvements de caméra, d'objets, ou en déclencheurs d'actions.
  • OrbitControls : Three.js fournit des utilitaires comme THREE.OrbitControls qui offrent des contrôles de caméra prêts à l'emploi (rotation, zoom, panoramique) via la souris, très utiles pour la navigation de débogage ou les visualisations interactives. Pour des expériences immersives, des contrôles plus spécifiques sont souvent nécessaires.
  • Contrôles Personnalisés : Pour des expériences VR/AR, des contrôles personnalisés sont essentiels. Ils peuvent impliquer des manettes de contrôleur VR, des gestes de la main (pour la RA), ou des mouvements de la tête. Ces contrôles interagissent généralement avec la position et l'orientation de la caméra, ou la transformation d'objets "curseurs" pour les interactions raycasting.

Optimisation et Performance

Pour des scènes immersives complexes, l'optimisation est cruciale pour maintenir un framerate élevé et une expérience fluide.

  • Frustum Culling : Three.js gère cela automatiquement. Seuls les objets dont le volume de délimitation (bounding box ou bounding sphere) intersecte le frustum (le volume visible par la caméra) sont rendus.
  • InstancedMesh : Pour afficher de très nombreux objets identiques avec des transformations différentes (position, rotation, échelle), InstancedMesh est une optimisation majeure. Au lieu de créer des milliers de Mesh individuels, vous utilisez une seule BufferGeometry et un seul Material, et vous passez les transformations de chaque instance au GPU via des attributs.
  • Level of Detail (LOD) : Affiche des versions plus détaillées des objets lorsqu'ils sont proches de la caméra, et des versions simplifiées lorsqu'ils sont éloignés. Cela réduit la charge de rendu pour les objets moins visibles.
  • Gestion des Ressources :
    • Réduire le nombre de Draw Calls : Combinez les géométries avec le même matériau pour qu'elles soient rendues en un seul appel de tirage si possible.
    • Optimiser les textures : Utilisez des textures de résolution appropriée, compressez-les, utilisez des atlas de textures.
    • Supprimer les objets inutiles : Retirez de la scène les objets qui ne sont plus visibles ou nécessaires.
    • Réutiliser les géométries et matériaux : Évitez de créer de nouvelles instances si une ressource existante peut être réutilisée.

Intégration dans des Expériences Immersives (Web AR/VR)

Ce que nous avons exploré aujourd'hui est directement applicable au développement d'expériences Web AR et Web VR, que vous utilisiez A-Frame ou Three.js directement.

  • A-Frame et Three.js : A-Frame est construit sur Three.js. Chaque entité A-Frame (<a-entity>) a un objet THREE.Object3D sous-jacent accessible via entity.object3D. Vous pouvez écrire des composants A-Frame qui manipulent directement les objets Three.js, créent des BufferGeometry ou appliquent des ShaderMaterial personnalisés. C'est le pont parfait pour combiner la facilité d'A-Frame avec la puissance brute de Three.js.
  • WebXR API : Les expériences Web AR/VR sont rendues possibles par l'API WebXR. Three.js fournit des adaptateurs pour cette API, permettant au WebGLRenderer de fonctionner en mode VR (stéréoscopique) ou AR (superposition sur la caméra réelle). Les techniques de personnalisation et de contrôle que nous avons vues sont essentielles pour construire des interfaces, des interactions et des visuels spécifiques à ces environnements immersifs.

En maîtrisant ces concepts avancés de Three.js, vous êtes en mesure de créer non seulement des scènes 3D statiques, mais aussi des mondes dynamiques, interactifs et visuellement riches qui peuvent servir de base à des applications Web AR/VR de pointe.

Conclusion

Nous avons fait un tour d'horizon des techniques avancées pour la personnalisation et le contrôle des scènes Three.js. De la création de géométries uniques avec BufferGeometry à la manipulation des pixels via les shaders GLSL, en passant par l'implémentation d'interactions complexes avec le Raycaster et l'optimisation des performances, vous avez désormais une boîte à outils plus robuste.

Points clés à retenir :

  • BufferGeometry est indispensable pour des formes 3D complexes et des performances optimales.
  • Les shaders GLSL (ShaderMaterial) sont la clé des effets visuels uniques et du rendu personnalisé, offrant un contrôle sans précédent sur l'apparence.
  • Le Raycaster est l'outil principal pour permettre l'interaction de l'utilisateur avec les objets 3D (clic, survol).
  • La boucle requestAnimationFrame est le cœur de toute animation fluide.
  • L'optimisation est une étape cruciale pour des expériences immersives performantes, notamment via InstancedMesh et la gestion des ressources.

Ces compétences sont directement transférables au développement d'expériences Web AR et Web VR, vous permettant d'aller au-delà des limites des frameworks déclaratifs pour construire des mondes virtuels et augmentés qui reflètent votre vision créative. Continuez à expérimenter et à explorer les possibilités infinies qu'offre Three.js !