Sauter au contenu

Cours 09 - useState et R3F

Le hook useState doit être importé depuis le package React et permet de déclarer un getter et un setter d’une variable que l’on voudra afficher et modifier à l’intérieur de la composante.

L’utilisation du setter lancera un nouveau rendu de la composante pour mettre à jour son jsx. Comme ces getters/setters sont retournés dans un tableau, il est d’usage d’utiliser la syntaxe de décomposition pour les déclarer:

'use client';
import {useState} from 'react';
const UneComposante = (props) => {
// useState nous retourne le getter puis le setter dans un tableau
// on passe la valeur initiale en paramètre
// le getter est passé en premier dans le tableau
// le setter est passé en deuxième dans le tableau
/*
const tableauNomState = useState('Jimmy'); //on passe la valeur initiale en paramètre
const nom = tableauNomState[0]; // le getter est passé en premier dans le tableau
const setNom = tableauNomState[1]; // le setter est passé en deuxième dans le tableau
*/
// On utilisera la décomposition d'un tableau pour réduire la taille du code utilisé
const [currentNom, setNom] = useState('Jimmy'); // remplace les 3 lignes de code ci-haut
const onClickHandler = () => {
setNom('Bob');
};
return (
<div>
<h3>{currentNom}</h3>
<button onClick={onClickHandler}>Clic!</button>
</div>
);
};

Selon ce qui est à faire, le développeur sera appelé à utiliser l’une ou l’autre des deux syntaxes à suivre pour mettre l’état à jour.

Si l’utilisateur souhaite remplacer la valeur de l’état sans utiliser la valeur précédente:

'use client';
import {useState} from 'react';
const UneComposante = (props) => {
const [currentNom, setNom] = useState('Jimmy');
const onClickHandler = () => {
setNom('Bob'); // La valeur précédente, Jimmy, n'est pas nécessaire ici
};
return (
<div>
<h3>{currentNom}</h3>
<button onClick={onClickHandler}>Clic!</button>
</div>
);
};

Si l’utilisateur souhaite remplacer la valeur de l’état en utilisant la valeur précédente:

'use client';
import {useState} from 'react';
const UneComposante = (props) => {
const [compteur, setCompteur] = useState(1);
const onClickHandler = () => {
// On utilise ici la valeur précédente de l'état pour l'incrémenter
// On passe une fonction qui reçoit la valeur précédente
// et retourne la nouvelle valeur
setCompteur(currentVal => currentVal + 1);
};
return (
<div>
<h3>{compteur}</h3>
<button onClick={onClickHandler}>Clic!</button>
</div>
);
};

Three.js est une librairie Javascript permettant facilement d’exploiter les APIs Webgl et WebGPU du navigateur afin de faire le rendu et de créer des expériences 3D interactives. Le code y est rédigé de façon impérative et nécessite beaucoup de travail manuel afin de créer une scène (instantiation et configuration d’un renderer, d’une scène, d’une caméra…). Ex:

import * as THREE from 'three';
// Initialisation de la scène, caméra et renderer
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// Création d'un mesh
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshBasicMaterial({ color: 0x4caf50 });
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);
camera.position.z = 5;
// onUpdate
function animate() {
requestAnimationFrame(animate);
cube.rotation.x += 0.01;
cube.rotation.y += 0.01;
renderer.render(scene, camera);
}
animate();
// Redimensionnement du navigateur
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});

R3F est une librairie supplémentaire développée par le collectif Poimandres permettant de déclarer le contenu d’une scène à l’aide de composantes R3F en plus d’initialiser automatiquement la plupart des éléments nécessaires. Tout demeure cependant du Three.js sous le capot.

Voici le code rédigé ci-haut en version R3F:

import React, { useRef } from 'react';
import { Canvas, useFrame } from '@react-three/fiber';
const Cube = () => {
const meshRef = useRef();
// onUpdate
useFrame(() => {
meshRef.current.rotation.x += 0.01;
meshRef.current.rotation.y += 0.01;
});
return (
<mesh ref={meshRef}>
<boxGeometry args={[1, 1, 1]} />
<meshBasicMaterial color="#4caf50" />
</mesh>
);
};
export default function App() {
// la caméra, le renderer et l'autoresize sont déjà gérées
// en utilisant la composante Canvas
return (
<Canvas camera={{ position: [0, 0, 5] }}>
<Cube />
</Canvas>
);
}

Les librairies Drei et Leva seront installées en plus de Three.js et R3F afin de profiter de différents outils utiles au développement.

Fenêtre de terminal
npm i three @react-three/fiber @react-three/drei leva

Tous les éléments propres à Three.js/R3F doivent être insérés dans des composantes client et dans une seule composante <Canvas>. Vous pourrez remplacer cette composante par <CanvasTIM> (déposée sur Teams dans _ressources).

  • showAxes: (booléen, true par défaut): Affiche les axes et leur sens dans la scène.
  • showGrid: (booléen, true par défaut): Affiche une grille de positionnement depuis l’origine.
  • useOrbitControls: (booléen, true par défaut): Permet de se déplacer et zoomer librement dans la scène. Utilise la librairie Drei.

Il vous est ensuite conseillé d’utiliser une composante différente pour la scène dans laquelle seront ajoutés les mesh 3D et l’éclairage:

"use client";
import CanvasTIM from "./CanvasTIM";
const ComposanteClient = () => {
return (
<div className="ClientWrapper">
<div>
<CanvasTIM>
<Scene />
</CanvasTIM>
</div>
</div>
);
};
const Scene = () => {
return (
<>
{ /* Regrouper les éléments de la scène ici */ }
</>
);
};
export default ComposanteClient;

Les meshes sont assemblés par imbrication en utilisant une composante <mesh> contenant habituellement une géométrie (forme) et un material (aspect visuel).

<mesh>
<boxGeometry/>
<meshBasicMaterial/>
</mesh>

Les éléments qui composent la scène peuvent également être regroupés (comme on le ferait dans un logiciel 3D) en utilisant la composante <group>:

<group>
<mesh>
<boxGeometry/>
<meshBasicMaterial/>
</mesh>
<mesh>
<boxGeometry/>
<meshBasicMaterial/>
</mesh>
</group>

Les propriétés des mesh peuvent être retrouvés dans la documentation de la classe Mesh ainsi que, par héritage, de la classe Object3D.

Tableau de 3 valeurs (x, y, z):

<mesh position={[0, 2, 1]}>

Tableau de 3 valeurs en radians sachant que 360 degrés = 2 * Math.PI.

<mesh rotation={[Math.PI / 4, 0, 0]}>

Tableau de 3 valeurs sachant que la taille actuelle de l’objet est égale à 1.

<mesh scale={[1.5, 1.5, 1.5]}>

Valeur booléenne. Active la visibilité de l’objet sans le détruire.

R3F rend possible l’utilisation d’écouteurs d’événements sans utiliser de Raycast manuellement.

  • onClick
  • onContextMenu: Right click ou long press sur mobile
  • onDoubleClick
  • onPointerUp: Bouton gauche ou droit
  • onPointerDown: Bouton gauche ou droit
  • onPointerOver & onPointerEnter
  • onPointerMove
  • onPointerMissed: Lancé si on clique à l’extérieur de l’objet qui écoute l’événement. Peut aussi être utilisé directement sur Canvas pour détecter n’importe quel clic (release) à l’intérieur sans ajouter un eventListener aux objets.

Comme tous les éléments de la scène 3d, la plupart des propriétés du mesh peuvent être déclarées lors de son instantiation (en les ajoutant dans le jsx).

Elles pourront être modifiées ensuite par programmation en les reliant à un ref (en utilisant useRef). On pourrait aussi appeler les méthodes de cette façon.

const MonMesh = () => {
const meshRef = useRef();
const clickHandler = () => {
meshRef.current.position.x = 3;
// ou
meshRef.current.position.set(0, 3, 0);
};
return (
<mesh ref={meshRef} position={[0, 1, 0]} onClick={clickHandler}>
{ /* ... */}
</mesh>
);
};

En Three.js, les objets sont créés par instantiation en utilisant leur méthode constructeur. Ex:

const geometry = new Three.BoxGeometry(1, 1, 1);

Selon la documentation du constructeur de BoxGeometry, les paramètres correspondent, dans l’ordre à la largeur, la hauteur et la profondeur de la géométrie.

En R3F, les paramètres sont passés au constructeur en utilisant toujours la prop args en lui passant les valeurs dans l’ordre sous la forme d’un tableau:

<mesh>
<boxGeometry args={[1, 1, 1]}/> // 1m * 1m * 1m
</mesh>

Les matériaux sont utilisés pour appliquer une couleur sur chaque “pixel” visible d’une géométrie.

Les algorithmes décidant de la couleur à appliquer se nomme des Shaders. ThreeJs a plusieurs matériaux et shaders pré-faits.

Propriétés de base d’un matériaux (qu’on utilise comme props en R3F)

Section intitulée « Propriétés de base d’un matériaux (qu’on utilise comme props en R3F) »
  • color: Instance de la classe color (new THREE.Color(‘red’))
  • map: Permet d’appliquer la texture
  • wireframe: booléen
  • transparent: booléen
  • opacity: float la propriété transparent doit être à true
  • alphamap: Texture alpha

Applique la couleur de façon uniforme, sans tenir compte de la lumière (donc complètement plat, sans ombre ni relief).

Applique un couleur en fonction des normales sur un objet 3d. Utile pour le déboggage, mais peut être utilisé tel quel parce que c’est joli.

Le plus performant utilisant la lumière. Supporte les mêmes propriétés que le MeshBasicMaterial, mais nécessite de la lumière pour fonctionner. DIfficile d’obtenir un rendu réaliste. Permet le rendu o’objets mats et non brillants.

Performance meilleure que pour le MeshStandardMaterial, mais moins que le MeshLambertMaterial. Permet la simulation de la brillance sur les surfaces. Propriétés supplémentaires:

  • shininess: (float) contrôle l’intensité de la réflexion de la lumière
  • specular: (couleur) contrôle la couleur de la réflexion de la lumière. Les valeurs sont un peu abstraites, non réalistes.

Pour faire du cellshading. Par défaut on a une coloration en deux parties. Une pour les ombres et une pour la partie éclairée.

Pour ajouter plus d’étapes, on peut utiliser une texture gradient sur la propriété gradientMap. P

ar défaut, les pixels sont étirés, on utilisera NearestFilter sur minFilter et magFilter et on désactivera le mipmapping.

Un standard, basé sur la physique et réagissant à la lumière de l’environnement.

Utilise les textures PBR:

  • map : Albedo / couleur de base
  • roughnessMap: En niveaux de gris, contrôle la rugosité de la surface (blanc = rugueux, noir = lisse)
  • metalnessMap: En niveaux de gris, détermine quelles zones de la surface sont métalliques (blanc) ou plastiques (noir).
  • normalMap: En RVB, simule des reliefs (micro) et détails comme des crevasses ou des bosses.
  • aoMap (ambient occlusion): En niveaux de gris, assombrit les recoins ou zones ou la lumière pénètre difficilement.
  • displacementMap: En niveaux de gris, déplace les sommets de la géométrie en 3d (nécessite de la subdivision).
  • bumpMap: Plus simple que normalMap, utilisant une carte de hauteur en niveau de gris.
  • emissiveMap: En RVB, détermine les zones émettant de la lumière ou brillant dans le noir.
  • alphaMap: En niveaux de gris, gère la transparence de la surface. Mettre la propriété transparent à true.
  • envMap: Utilisé pour les reflets du monde autour de l’objet.

Une extension de MeshStandardMaterial, supporte d’autres effets comme:

  • clearcoat (float) et clearcoatRoughness (float): Ajoutent une mince couche de vernis sur le matériau. Possède ses propres propriétés de réflexion. Affecte la performance.
  • sheen (float), sheenRoughness (float), sheenColor (color): Ajoute un reflet au matériau quand il est vu depuis un angle aigu. Souvent utilisé sur du tissus (chatoiement?).
  • iridescence (float), iridescenceIOR (float), iridescenceThicknessRange ([min, max]): Crée un reflet de couleur comme sur une flaque d’essence, du savon, un disque laser…
  • transmission (float), ior (index de réfraction, float), thickness (float): Permet à la lumière de traverser le matériau. Contrairement à l’opacité, l’image derrière le matériau avec transmission est déformée. Pour le ior, utiliser la valeurs absolues (Eau: 1.333, Air: 1.000293, Diamant: 2.417)

Lumière omnidirectionelle, uniforme. Tous les objets sont éclairés de la même façon. Solution à faible coût pour simuler un éclairage indirect. On l’utilise presque à tous coups pour ajouter une couleur de base aux objets et très couramment en combinaison avec une lumière DirectionalLight.

  • couleur
  • intensité

Lumière semblable au soleil. Comme si les rayons se déplaçaient en parallèle.

  • couleur
  • intensité
import { useRef } from 'react'
import { useHelper } from '@react-three/drei'
import { DirectionalLightHelper } from 'three'
const SceneLight = () => {
const dirLightRef = useRef();
// useHelper(reference, helperConstructor, size, color)
useHelper(dirLightRef, DirectionalLightHelper, 2, 'red')
return (
<>
<directionalLight ref={dirLightRef} position={[5, 5, 5]} intensity={1.5} />
</>
)
}

Une lumière émise dans toutes les directions depuis un point précis. Utilisée souvent pour remplacer une ampoule. Cette lumière peut émettre des ombres.

  • couleur
  • intensité
  • distance
  • decay (désintégration): Vitesse à laquelle la vitesse s’atténue avec la distance.
import { useRef } from 'react'
import { useHelper } from '@react-three/drei'
import { PointLightHelper } from 'three'
const Scene = () => {
const pointLightRef = useRef()
useHelper(pointLightRef, PointLightHelper, 0.5, 'blue')
return (
<>
<pointLight
ref={pointLightRef}
position={[-3, 3, 2]}
intensity={2}
/>
</>
)
};

Une lumière émise dans une direction depuis un point précis. Elle est diffusée sous la forme d’un cône qui s’élargit en s’éloignant de son origine. Cette lumière peut émettre des ombres.

  • couleur
  • intensité
  • distance
  • angle: ouverture en radians
  • pénombre: la finesse des bordures du cône
  • target: Par défaut le SpotLight pointera vers l’origine (0, 0, 0). La modification de sa rotation ne fonctionnera pas à le changer. Il faut modifier sa propriété target en pointant vers un objet de la scène (en utilisant son ref). Il est possible d’utiliser un object3d (un peu comme un GameObject vide) comme cible.
import { useRef } from 'react'
import { useFrame } from '@react-three/fiber'
import { useHelper } from '@react-three/drei'
import { SpotLightHelper } from 'three'
const SpotLightScene = () => {
const spotLightRef = useRef()
const helper = useHelper(spotLightRef, SpotLightHelper, 'teal')
// Forcer la mise à jour du helper si la lumière ou sa cible bouge
useFrame(() => {
if (helper.current) {
helper.current.update()
}
})
return (
<>
<spotLight
ref={spotLightRef}
position={[0, 8, 0]}
angle={Math.PI / 6}
penumbra={0.5}
intensity={3}
/>
</>
)
};

Une source lumineuse positionnée directement au dessus de la scène avec un dégradé entre une couleur pour le ciel et une autre pour la sol. Ne permet pas de projeter des ombres:

  • couleur du ciel
  • couleur du sol
  • intensité
import { useRef } from 'react';
import { Canvas, useHelper } from '@react-three/drei';
import { HemisphereLightHelper } from 'three';
const SceneWithHelper = () => {
const hemiLightRef = useRef();
useHelper(hemiLightRef, HemisphereLightHelper, 2);
return (
<>
<hemisphereLight
ref={hemiLightRef}
args={['#ff0055', '#00aaff', 1.5]}
position={[0, 10, 0]}
/>
<mesh position={[0, 0, 0]}>
<sphereGeometry args={[1, 32, 32]} />
<meshStandardMaterial roughness={0.3} />
</mesh>
</>
)
};

Éclairages les plus courants (suggérés par un ami à moi)

Section intitulée « Éclairages les plus courants (suggérés par un ami à moi) »
const Eclairage = () => {
return (
<>
<ambientLight intensity={0.5} />
<directionalLight position={[5, 5, 5]} intensity={1.0} />
</>
)
};

Permet de détacher un objet du fond sans laisser de zones noires

const Eclairage = () => {
return (
<>
<directionalLight
position={[5, 5, 5]}
intensity={1.5}
castShadow
shadow-mapSize={[2048, 2048]}
/>
<directionalLight
position={[-5, 3, 5]}
intensity={0.4}
/>
<directionalLight
position={[0, 5, -5]}
intensity={2.0}
/>
<ambientLight intensity={0.1} />
</>
)
}
const Eclairage = () => {
return (
<>
{/* Lumière du ciel : Ciel bleu clair et sol */}
<hemisphereLight
args={['#87ceeb', '#2c1d11', 0.6]}
/>
{/* Lumière jaune/blanche chaude très puissante pour reproduire le soleil */}
<directionalLight
position={[10, 12, 8]}
intensity={2.5}
castShadow
shadow-mapSize={[2048, 2048]}
shadow-bias={-0.0001}
/>
</>
)
}
const Eclairage = () => {
return (
<>
<ambientLight intensity={0.7} />
<directionalLight position={[4, 8, 4]} intensity={0.8} />
<directionalLight position={[-4, 8, 4]} intensity={0.8} />
<directionalLight position={[0, 10, 0]} intensity={0.5} castShadow />
</>
)
}

Il y a trois étapes à suivre afin de permettre la projection d’ombres dans une scène dans R3F.

  1. Pour permettre les ombres la prop shadows doit être activée sur la composante <Canvas> (à l’intérieur de <CanvasTIM> dans votre cas):
    Activer les ombres sur le canvas
    <Canvas
    shadows
    {/* d'autres props */}
    >
    {/* ... */}
    </Canvas>
  2. Les lumières devant émettre des ombres (et les supportant) doivent porter le prop castShadow. Les lumières qui le supportent sont les <directionalLight/>, <spotLight/> et <pointLight/>.
    Activer l'émission d'ombres sur les lumières
    <directionalLight
    castShadow
    position={[-5, 3, 5]}
    intensity={0.4}
    />
  3. Les meshes doivent aussi permettre d’émettre des ombres (castShadow) et/ou afficher celles qui sont projetées sur lui (receiveShadow).
    Permettre aux meshes de recevoir et/ou émettre une ombre
    <mesh position={[0, 1, 0]} castShadow receiveShadow>
    <boxGeometry args={[1, 1, 1]} />
    <meshStandardMaterial color="royalblue" />
    </mesh>

Il donne accès à l’état interne de l’application. Il permet donc de récupérer la référence de la caméra active, la taille de l’écran, le renderer ou la scène globale. Ne pas l’utiliser pour animer quoi que ce soit. Utiliser plutôt useFrame.

import { useThree } from '@react-three/fiber'
const CameraController = () => {
// Extraction des éléments clés du state
const { camera, size, gl, scene } = useThree()
const clickHandler = () => {
// Change la position de la caméra au clic
camera.position.set(0, 5, 10)
camera.updateProjectionMatrix()
}
return <mesh onClick={clickHandler} />
};

Boucle d’animation. Semblable au onUpdate de Unity. Exécutée en générale en 60 et 120 fois par secondes. useFrame reçoit le state de useThree ainsi que le delta (le temps écoulé en secondes depuis le dernier update) en paramètre. On multiplie donc souvent les modifications par delta afin d’obtenir des animations fluides.

import { useRef } from 'react'
import { useFrame } from '@react-three/fiber'
const CubeRotatif = () => {
const meshRef = useRef()
useFrame((state, delta) => {
meshRef.current.rotation.y += 1 * delta; // 1 radian par seconde
const elapsedTime = state.clock.getElapsedTime();
meshRef.current.position.y = Math.sin(elapsedTime);
})
return (
<mesh ref={meshRef}>
<boxGeometry />
<meshStandardMaterial />
</mesh>
)
};

Utilisé pour charger des ressources externes (modèles, textures, fichiers audio).

import { useLoader } from '@react-three/fiber';
import * as THREE from 'three';
const TexturedCube = () => {
const texture = useLoader(THREE.TextureLoader, '/textures/wood_color.jpg');
// Optionnel : Configurer la répétition de la texture
// texture.wrapS = THREE.RepeatWrapping;
// texture.wrapT = THREE.RepeatWrapping;
// texture.repeat.set(2, 2)
return (
<mesh castShadow receiveShadow>
<boxGeometry args={[2, 2, 2]} />
<meshStandardMaterial
map={texture}
roughness={0.4}
metalness={0.1}
/>
</mesh>
)
};

Voir plutôt l’outil GLTF > R3F de Poimandres.

Permet de lancer les différentes animations d’un modèle. Souvent obtenu depuis l’utilisation de useGLTF.

const { actions, names } = useAnimations(animations, group);
  • actions est un objet ayant le nom des différentes actions comme propriété
  • names est une liste contenant les actions sous forme de string

On lance une animation par:

actions['nom-action'].play();

Si on lance plus d’une animation en séquence, celles-ci se combineront plutôt que d’effectuer une transition. On procède donc ainsi:

  1. Appeler la fonction fadeOut() sur l’action en cours.
  2. Appeler reset().fadeIn().play() sur la nouvelle action.

Dans un useEffect:

// Dans cet exemple, newAnimation serait un string
// correspondant au nom de la nouvelle animation à jouer
useEffect(() => {
const action = actions[newAnimation];
if (!action) {
return;
}
action.reset().fadeIn(0.3).play();
return () => {
action.fadeOut(0.3);
};
}, [newAnimation])

Leva est une librairie développée par Poimandres permettant d’afficher des panneaux GUI à l’écran afin de débogguer et faire le réglage de certains paramètres des objets 3d. On pourrait y reproduire quelque peu le comportement des propriétés sérialisées de Unity.

On l’utilisera en phase de prototypage pour appliquer ensuite définitivement les valeurs trouvées. La modification des valeurs du GUI déclenchent un nouveau rendu à chaque fois et ont donc un impact significatif sur les performances.

Pour ajouter des contrôles, on utiliser tout simplement useControls depuis la composante ciblée en donnant des noms aux propriétés à ajuster sous la forme d’un objet:

import { useControls } from 'leva';
const Cube = () => {
const { position, color, wireframe } = useControls({
// Type Vecteur/Position (génère 3 inputs X, Y, Z)
position: {
value: { x: 0, y: 0, z: 0 },
step: 0.1,
},
// Type Couleur (génère un color picker)
color: '#ff0055',
// Type Booléen (génère une checkbox)
wireframe: false,
})
return (
<mesh position={position}>
<boxGeometry />
<meshStandardMaterial color={color} wireframe={wireframe} />
</mesh>
)
};

On peut également utiliser des valeurs minimales/maximales (min, max), ajouter une étiquette (label) ainsi que contrôler la valeur de chaque augmentation/réduction pour chaque propriété (step):

const { intensity, scale } = useControls({
intensity: {
value: 1.5,
min: 0,
max: 5,
step: 0.1,
label: 'Intensité'
},
scale: {
value: 1,
options: [1, 2, 3] // Menu déroulant
},
reset: button(() => console.log('Il faut importer button!'))
});

Un nom optionnel peut être passé comme premier paramètre de useControls:

const { intensity, scale } = useControls('Lumière et intensité', {
intensity: {/*...*/},
scale: { /* ... */}
});

Des sous-dossiers peuvent également être ajoutés en utilisant un folder (qui doit être importé):

const { intensity, scale } = useControls('Lumière et intensité', {
intensity: {/*...*/},
scale: { /* ... */},
Apparence: folder({
color: '#ff0055',
wireframe: false,
})
});

Drei est une librairie d’extensions et d’outils permettant le développement plus agréable. En plus de l’utilisation démontrée dans les exemples précédents, il peut être amusant d’aller s’aventurer dans la liste de leur ajouts et d’en essayer plusieurs.