INFO804 Introduction à l'informatique graphique
Loading...
Searching...
No Matches
TP3 Un peu de WebGL et de shaders avec three.js

author: Jacques-Olivier Lachaud

1 - Objectifs, pointeurs utiles, mise en place

L'objectif de ce TP est de vous montrer comment utiliser la puissance graphique de vos ordinateurs dans de simples pages web. Ainsi, vous pourrez intégrer dans vos pages web de la 3D, du rendu (presque) réaliste temps réel, des jeux, des effets spéciaux 2D ou 3D, etc. Les technologies utilisées ici sont le javascript, la bibliothèque WebGL (une surcouche d'OpenGL pour le javascript), et la bibliothèque three.js, qui simplifie considérablement l'utilisation de WebGL. Nous toucherons aussi un petit peu aux shaders, qui sont des programmes compilés par et exécutés sur la carte graphique.

1.1 - Installation de three.js et d'un serveur web local

Il suffit de le télécharger de son site officiel (https://threejs.org). Il est mis aussi dans l'archive du TP. Ensuite, pour pouvoir exécuter facilement vos codes html/js/webgl, il faut qu'il y ait un serveur web (http/https) qui tourne sur votre machine. C'est nécessaire, car pour des raisons de sécurité, votre navigateur interdirait le chargement de fichiers extérieurs. Si vous n'en avez pas déjà un (il suffit de voir si http://localhost:8080 donne qqchose), vous pouvez installer Servez (https://github.com/greggman/servez), qui s'installe et se lance facilement. Ensuite, normalement le premier exemple de code three.js devrait s'exécuter sans problème.

Note
Lorsque ça ne marche pas (en général, la fenêtre WebGL reste noire), avec Chrome, allez dans View -> Developer > Developer tools ou Afficher -> Options pour les développeurs -> Console JavaScript. Les erreurs sont affichées, la console, etc.
Un bon point de départ est la documentation three.js. Les exemples sont aussi une mine d'information.
J'utilise ici la release r160 de three.js.

1.2 - Parties essentielles d'un code WebGL avec three.js

Comme vous fabriquez une page web, vous avez besoin d'un fichier HTML, éventuellement d'un fichier de style CSS. De plus, il faut charger les bibliothèques javascript utiles dans la page. Enfin, il faut prévoir un champ canvas qui contiendra la fenêtre WebGL et lui donner un nom (ici webglcanvas).

<!DOCTYPE html>
<html lang="en">
<head>
<title>A small introduction to three.js webgl [1]</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
<link rel="stylesheet" href="css/basic.css">
</head>
<body>
<div id="info"><a href="https://threejs.org" target="_blank" rel="noopener">three.js</a> Q1: a rotating WebGL cube</div>
<canvas id="webglcanvas" style="border: none;background-color:#000000"
width="800" height="600"></canvas>
<script type="importmap">
{
"imports": {
"three": "./three.module.js",
"three/addons/controls/": "./examples/jsm/controls/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
// (I) Put all your code here.
</script>
</body>
</html>

Maintenant voilà le code javascript pour faire un rendu minimal 3D. Notez que l'utilisation de three.js facilite grandement la création de scènes 3D et leur rendu. Le même code WebGL "bas niveau" prendrait pas loin de 150 lignes. Notamment il faudrait gérer à la main la communication des données entre le javascript et les shaders. Ici, three.js se charge de tout ou presque.

<script type="module">
import * as THREE from 'three';
var renderer = null;
var scene = null;
var camera = null;
var cube = null;
var curTime = Date.now();
var canvas = document.getElementById("webglcanvas");
// Checks that your browser supports WebGL.
if ( ! ( window.WebGLRenderingContext
&& ( canvas.getContext('webgl')
|| canvas.getContext('experimental-webgl')) ) )
console.log( "WebGL not supported on your browser." );
init();
run();
// This function is called whenever the document is loaded
function init() {
// Create the Three.js renderer and attach it to our canvas
renderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: true } );
// Set the viewport size
renderer.setSize( canvas.width, canvas.height );
// Create a new Three.js scene
scene = new THREE.Scene();
// Add a camera so we can view the scene
camera = new THREE.PerspectiveCamera( 45, canvas.width / canvas.height, 1, 4000 );
// Create a texture-mapped cube and add it to the scene
// First, create the texture map
var mapUrl = "images/webgl-logo-256.jpg";
var map = new THREE.TextureLoader().load( mapUrl );
// Now, create a Basic material; pass in the map
var material = new THREE.MeshBasicMaterial({ map: map });
// Create the cube geometry
var geometry = new THREE.BoxGeometry(2, 2, 2);
// And put the geometry and material together into a mesh
cube = new THREE.Mesh(geometry, material);
// Move the mesh back from the camera and tilt it toward the viewer
cube.position.z = -8;
cube.rotation.x = Math.PI / 5;
cube.rotation.y = Math.PI / 5;
// Finally, add the mesh to our scene
scene.add( cube );
}
// This function is called regularly to update the canvas webgl.
function run() {
// Ask to call again run
requestAnimationFrame( run );
// Render the scene
render();
// Calls the animate function if objects or camera should move
animate();
}
// This function is called regularly to take care of the rendering.
function render() {
// Render the scene
renderer.render( scene, camera );
}
// This function is called regularly to update objects.
function animate() {
// Computes how time has changed since last display
var now = Date.now();
var deltaTime = now - curTime;
curTime = now;
var fracTime = deltaTime / 1000; // in seconds
// Now we can move objects, camera, etc.
// Example: rotation cube
var angle = 0.1 * Math.PI * 2 * fracTime; // one turn per 10 second.
cube.rotation.y += angle;
}
</script>

En gros, un code WebGL suit toujours les étapes suivantes:

D'abord l'initialisation:

  1. vérification que votre navigateur comprend WebGL,
  2. récupération d'une zone de dessin (canvas) dans la page HTML, puis création d'un objet renderer qui calculera le rendu,
  3. création d'une scène où les objets graphiques et les lumières seront placées, et d'une caméra (au moins).

Ensuite, les autres fonctions sont utilisées régulièrement (plusieurs fois pour seconde) pour effectuer le rendu temps réel, l'animation et l'interaction avec l'utilisateur.

  • la fonction run doit être appelée une première fois puis demande au navigateur à être rappelée dès que nécessaire via requestAnimationFrame. Ensuite, elle appelle juste render (pour faire le rendu) et animate pour mettre à jour les positions, la géométrie, les couleurs, etc. A priori, nous n'avez pas besoin d'y toucher.
  • la fonction render effectue le rendu souhaité. On voit qu'il suffit de la modifier si on veut faire plusieurs zones de rendu avec plusieurs camera.
  • la fonction animate sert à mettre à jour la géométrie de la scène en fonction du temps. On récupère la variation de temps depuis le dernier affichage puis on met à jour les objets en déplacement.

Dans l'exemple précédent, la scène se réduit à un cube, placé en (0,0,-8), avec un material qui est juste une texture. La caméra est elle placée en ses coordonnées par défaut (0,0,0) et regarde vers les z négatifs. L'animation fait juste tourner l'axe y du cube (au sens des coordonnées d'Euler).

Premier exemple webgl
Note
Si vous voulez afficher sur toute la fenêtre plutôt que juste un canvas, vous pouvez remplacer la création du WebGLRenderer ainsi
renderer = new THREE.WebGLRenderer( { antialias: true } );
renderer.setSize( window.innerWidth, window.innerHeight );

2 - Premiers pas en three.js

2.1 - D'autres géométries

Les objets géométriques 3D sont représentés à l'aide de Mesh, et combinent deux propriétés: leur géométrie (simple comme cube ou sphère, complexe comme des surfaces triangulées ou des extrusions de splines), et leur matériau (couleur, propriétés diffuses ou spéculaires, textures (diffuses ou spéculaires), carte de normales et bumpmap, etc).

Three.js vous fournit plein de classes toutes faites pour la géométrie:

Exercice 2.1. Remplacez le cube (BoxGeometry) par une sphère de rayon 1. Remplacez la texture webgl par la texture earth_atmos_2048.jpg.

Pour faire plusieurs objets, il suffit de les ajouter à la scène. On verra ci-dessous comment les grouper, ce qui facilitera grandement le placement et l'animation des objets.

Note
three.js, contrairement à ce que son nom indique, propose aussi des géométries "2D", et peut faire du rendu 2D.

2.2 - Matériaux et lumières

Pour donner un aspect plus 3D à nos scènes, il manque deux ingrédients fondamentaux: des lumières et des matériaux sensibles à la position des lumières et de la caméra. Là encore, nous disposons de plusieurs classes pour faire de l'illumination: AmbientLight, DirectionalLight, PointLight, SpotLight sont les plus courantes.

Il suffit aussi d'instancier et d'ajouter une lumière à une scène pour qu'elle soit prise en compte.

// Add a white point light, which lights at infinite distance and without decay
// with the distance.
var light = new THREE.PointLight( 0xffffff, 2.5, 0.0, 0.0 );
light.position.x = ...;
scene.add( light );

Ensuite, on peut créer un matériau qui est sensible aux lumières. Plusieurs matériaux classiques ou moins classiques sont fournis par three.js: MeshBasicMaterial , MeshDepthMaterial , MeshLambertMaterial , MeshNormalMaterial , MeshPhongMaterial , MeshPhysicalMaterial , MeshStandardMaterial , MeshToonMaterial , etc. Ce qui est pratique, c'est que three.js genère tout le code du vertex shader et du fragment shader à votre place.

Note
Si vous voulez écrire votre propre shader, la classe ShaderMaterial permet de le faire.

Le côté remarquable est que l'on peut choisir comme on veut les matériaux pour des objets différents. Toute la difficulté d'associer le bon shader aux bons objets est géré en interne par three.js.

Exercice 2.2. Remplacez le matériau de votre sphère par un MeshPhongMaterial . Donnez-lui une spécularité blanche bien visible.

2.3 - Groupes et transformations géométriques

La scène est organisée sous forme d'un arbre. Les objets géométriques ne sont pas seulement listés dans une scène géométrique, mais sont plutôt organisés sous forme d'une hiérarchie. L'intérêt (en dehors des aspects performances lorsqu'on ajoute des niveaux de détails) est qu'un transformation géométrique appliquée à un noeud est appliquée à tous les descendants du noeud. Ainsi, si on veut créer une voiture, on créera un noeud voiture, puis des descendants comme le chassis ou la carosserie, et les roues seront des descendants du chassis. On pourra faire tourner les roues par rapport au chassis, et un mouvement global de la voiture déplacera tout ce beau monde.

Tout objet géométrique (descendant de Object3D) peut avoir des fils, mais en général on préfère utiliser un Group pour rassembler des objets géométriques.

Exercice 2.3. Créer une scène de type "système solaire" avec au centre le soleil (vous placez une lumière PointLight et une sphère), autour une planète terre (qui tourne autour du soleil), et autour de la terre une lune qui tourne autour. Vous pouvez aussi faire tourner la terre sur elle-même, sachant qu'elle a un axe tourné par rapport au soleil !

// la terre est inclinée par rapport à son orbite.
earth.rotation.x = Math.PI / 5;
Note
N'oubliez pas qu'il y a des champs position.x, position.y, rotation.x, etc, dans chaque objet géométrique ou groupe. Vous aurez besoin de rajouter des variables globales et de créer des groupes pour rassembler les planètes et aussi les faire tourner autour du soleil. Le déplacement des angles dans animate devraient ressembler à ça:
var angle = fracTime * Math.PI * 2;
// Notez que l'axe y est l'axe "vertical" usuellement.
earthGroup.rotation.y += angle / 365; // la terre tourne en 365 jours
earth.rotation.y += angle; // et en un jour sur elle-même
moonGroup.rotation.y += angle / 28; // la lune tourne en 28 jours autour de la terre
moon.rotation.y += angle /28; // et en 28 jours aussi sur elle-même pour faire face à la terre
Un petit système solaire
Note
Il faut bien réfléchir aux groupes pour placer les planètes, si on veut que la terre puisse tourner autour du soleil et autour d'elle-même et que la lune puisse tourner autour de la terre. Un earthGroup est positionné sur le soleil et va tourner sur lui-même en 1 an. Un earthSystem, fils de earthGroup est translaté relativement à son père (par exemple de 5 selon x). La terre earth, fille de earthSystem tourne sur elle-même en 1 jour...

2.4 - Contrôle de la caméra par le programme

De la même façon qu'on peut déplacer les objets, on peut déplacer la caméra pour voir la scène sous d'autres angles et positions. Il suffit par exemple de modifier les champs camera.position ou camera.lookAt pour déplacer la caméra. Ajoutez la ligne ci-dessous dans animate:

var pos = new THREE.Vector3;
pos.setFromMatrixPosition( earth.matrixWorld );
camera.lookAt( pos );

De façon remarquable, la caméra pointe toujours vers la terre. Notez que l'on doit demander les coordonnées de la terre en coordonnées absolues dans le monde, car ses coordonnées relatives sont fixes en fait (ici, (5,0,0)).

Exercice 2.4. Mettez la camera sur une orbite elliptique en définissant une variable globale cameraAngle que vous mettrez à jour dans animate(), puis utilisez l'équation paramétrique de l'ellipse pour placer directement la camera:

// Avec un grand demi-axe de 5 et un petit demi-axe de 3
camera.position.x = 5 * Math.cos( cameraAngle );
camera.position.y = 3 * Math.sin( cameraAngle );
Un petit système solaire, vue d'un astéroïde

2.5 - Contrôle de la caméra par la souris

On peut bien sûr aussi piloter les déplacements de la caméra via une souris ou le clavier. L'idée est de définir des callbacks pour des événements. Cela ressemble à ça:

domElement.addEventListener( 'contextmenu', onContextMenu, false );
domElement.addEventListener( 'mousedown', onMouseDown, false );
domElement.addEventListener( 'wheel', onMouseWheel, false );
domElement.addEventListener( 'touchstart', onTouchStart, false );
domElement.addEventListener( 'touchend', onTouchEnd, false );
domElement.addEventListener( 'touchmove', onTouchMove, false );
...

Faire une caméra manipulable prend beaucoup de temps. Heureusement, three.js fournit quelques classes toutes faites pour contrôler la caméra. Nous utiliserons le script OrbitControls.js (tiré des exemples). Une fois chargé, il suffit d'instancier un OrbitControls, de le paramétrer, et d'appeler régulièrement sa méthode update.

// Après import de three.js
import { OrbitControls} from 'three/addons/controls/OrbitControls.js';

et dans init()

controls = new OrbitControls( camera, renderer.domElement );
// controls.addEventListener( 'change', render ); // call this only in static scenes (i.e., if there is no animation loop)
controls.enableDamping = true; // an animation loop is required when either damping or auto-rotation are enabled
controls.dampingFactor = 0.25;
controls.screenSpacePanning = false;
controls.minDistance = 1;
controls.maxDistance = 10;
controls.maxPolarAngle = Math.PI / 2;

Et dans animate:

controls.update();

Exercice 2.5. Ajouter un contrôleur "orbite" qui pilote maintenant la camera (vous devez enlever certaines lignes faites à la question précédent). Modifiez la cible du contrôleur (target) pour que ce soit la terre.

2.6 - Mettre un fond à votre scène

Pour mettre un fond à une scène, on fabrique une sphère ou un cube "lointain" et on plaque dessus – plus précisément dessous – une texture. Ces fonds sont souvent utilisés aussi en tant qu' environment map pour faire des effets de reflets jolis (e.g. https://threejs.org/examples/?q=en#webgl_materials_envmaps). Three.js donne même un champ background à une scène pour représenter un fond. Voilà comment rajouter la voie lactée en fond.

// dans init()
// Add background
var path = "images/MilkyWay/";
var format = '.jpg';
var urls = [
path + 'posx' + format, path + 'negx' + format,
path + 'posy' + format, path + 'negy' + format,
path + 'posz' + format, path + 'negz' + format
];
var textureCube = new THREE.CubeTextureLoader().load( urls );
textureCube.type = THREE.UnsignedByteType;
textureCube.format = THREE.RGBAFormat;
scene.background = textureCube;

Exercice 2.6. Ajouter le fond "MilkyWay" ou "skybox". Cela donne:

Avec la voie lactée en fond

2.7 - Calculer des ombres entre objets

Par défaut, le rendu graphique ne tient pas compte d'ombres possibles entre objets suivant la position de la lumière. Comme on a pu voir dans le TP ray-tracing, c'était un peu coûteux à calculer.

Le principe pour calculer des ombres (assez) efficacement est de faire des "cartes d'ombres" (shadowmap). En fait, pour chaque source de lumière qui peut faire de l'ombre, on fait un rendu (très simplifié, juste la profondeur est conservée) des objets qui peuvent faire de l'ombre du point de vue de la lumière. La profondeur mémorisée en chaque point permet ensuite, lorsqu'on fait le rendu du point de vue de la caméra, de voir si le point considéré est dans l'ombre, en regardant la profondeur en ce point.

Note
On voit néanmoins que le renderer est obligé de faire plusieurs rendu suivant chaque lumière, ce qui peut être coûteux lorsque les scènes sont complexes.

En three.js, c'est très simple de rajouter de l'ombre. Il faut d'abord indiquer au renderer qu'il y aura des ombres à calculer:

renderer.shadowMap.enabled = true;
// rendu coûteux mais plus joli (default: THREE.PCFShadowMap)
renderer.shadowMap.type = THREE.PCFSoftShadowMap;

Ensuite, il faut préciser quelles sont les lumières qui peuvent faire de l'ombre. Nous, on va faire en sorte que la lumière du soleil puisse faire de l'ombre.

light.castShadow = true;
// On peut aussi paramétrer la qualité du calcul
light.shadow.mapSize.width = 512; // default
light.shadow.mapSize.height = 512; // default
light.shadow.camera.near = 0.5; // default
light.shadow.camera.far = 50;

Enfin, on précise quels objets peuvent faire de l'ombre, et quels objets peuvent recevoir de l'ombre.

sun.castShadow = false;
sun.receiveShadow = false;
earth.castShadow = true;
earth.receiveShadow = true;
moon.castShadow = true;
moon.receiveShadow = true;

Exercice 2.7. Ajouter l'ombrage de la terre et de la lune. Vous devriez maintenant voir des éclipses !

2.8 - Ecrire votre propre shader

On va écrire un nouveau material en utilisant la classe ShaderMaterial . Il faut d'abord écrire 2 scripts, l'un pour le vertex shader, l'autre pour le fragment shader.

Le vertex shader s'occupe de calculer les coordonnées de chaque sommet de l'objet dans la vue de la caméra, ainsi que le vecteur normal en ce sommet. On note les 2 variables moment et scale (dites uniform) qui sont partagés par les shaders et envoyés par le ShaderMaterial (voir ci-dessous). Par rapport à un shader classique, celui-ci s'amuse à gonfler et dégonfler périodiquement l'objet.

<script id="post-vert" type="x-shader/x-vertex">
// Vertex shader
uniform float moment; // (général) temps écoulé depuis le début
uniform float scale; // (général) paramètre donnant le gonflement périodique
out vec3 fn; // (sortie) normale au sommet (repère écran)
out vec3 vertPos; // (sortie) coordonnées 3D du sommet (repère écran)
out vec3 gblPos; // (sortie) coordonnées 3D du sommet (repère général)
void main() {
float t = mod( moment, 6.28318530717958647688 );
// On gonfle légèrement le soleil périodiquement en fonction du temps.
vec3 scaled_pos = position + scale * (1.0+cos( 2.0*t)) * normal;
gblPos = scaled_pos;
// On calcule le vecteur normal au sommet dans le repère écran
fn = vec3( normalMatrix * normal );
// On calcule la position du sommet dans le repère caméra
vec4 vertPos4 = modelViewMatrix * vec4( scaled_pos, 1.0 );
// On envoie au fragment shader la position du sommet dans
// le repère camera.
vertPos = vec3(vertPos4) / vertPos4.w;
// On indique à OpenGL la position du sommet dans le repère écran/pixel
gl_Position = projectionMatrix * vertPos4;
}
</script>

Le fragment shader s'occupe de calculer un couleur pour chaque pixel de l'objet à afficher. On récupère le temps (variable uniform moment) et on mélange coordonnées globales et vecteur normal pour fabriquer une couleur.

<script id="post-frag" type="x-shader/x-fragment">
// Fragment/pixel shader
in vec3 fn; // (entrée) normale du pixel (repère écran) (interpolée)
in vec3 vertPos; // (entrée) coordonnées 3D du pixel (repère écran)(interpolées)
in vec3 gblPos; // (entrée) coordonnées 3D du pixel (repère général)(interpolées)
uniform float moment; // (général) temps écoulé depuis le début
out vec4 outColor; // (sortie) la couleur de sortie du pixel
void main() {
float t = mod( moment, 6.28318530717958647688 ); // temps
vec3 n1 = normalize(fn); // vecteur normal au soleil (repère écran)
float l = abs( n1.z ); // proche de 0 sur les bords du soleil
outColor.rgb = vec3( 1.0 , 0.6+0.4* cos( 4.*gblPos.x + 3.*t ), l );
outColor.a = 1.0;
}
</script>

Il faut maintenant créer le ShaderMaterial et l'associer à une géométrie.

var shader;
var uniforms;
...
// dans init()
uniforms = {
moment: { value: 0.0 },
scale: { value: 0.02 }
};
shader = new THREE.ShaderMaterial( {
vertexShader: document.querySelector( '#post-vert' ).textContent.trim(),
fragmentShader: document.querySelector( '#post-frag' ).textContent.trim(),
uniforms: uniforms
} );
shader.glslVersion = THREE.GLSL3;
// On réutilise la géométrie de sphère de rayon 1.
var sunHalo = new THREE.Mesh( geometry, shader );
sunHalo.castShadow = false;
sunHalo.receiveShadow = false;
sunGroup.add( sunHalo );
...
// dans animate()
// On change le `moment` pour le shader.
shader.uniforms.moment.value += fracTime;

Ici on a rajouté un halo au soleil, mais on pourrait remplacer le soleil ou créer une autre planète sur le même principe.

Le soleil est sur le point d'exploser en super-nova
Note
Pour aller plus loin, les shaders par défaut de three.js sont écrits par morceaux sous formes de chunks (dans three.js/src/renderers/shaders/ShaderChunk) puis assemblés par des #include dans les matériaux (dans three.js/src/renderers/shaders/ShaderLib).

3 - A vous de jouer

Il y a bien sûr plein d'autres fonctionnalités dans three.js et WebGL. Nous n'avons fait qu'effleurer le sujet.

Je vous invite à regarder les multiples exemples de three.js, qui donnent plein d'idées.

Vous pouvez compléter le système solaire, faire des planètes métalliques, des planètes de lave, des astéroïdes multiples sous forme de particules, utiliser d'autres materials, ou faire vos shaders, en vous inspirant de celui donné plus haut ou en regardant quelques exemples que j'ai mis sur mon site https://jacquesolivierlachaud.github.io/lectures/info804/ .

Vous pouvez aussi faire quelque chose de complètement différent, dans la mesure où c'est un travail personnel.

Bref, étonnez-moi !

Vous me remettrez votre TP via TPLab seul ou en binôme avant le vendredi 23 février 2024 minuit, avec un README expliquant ce que vous avez fait d'original en plus des questions posées. Si vous préférez, vous pouvez aussi uploader votre TP sur un serveur web et me donner le lien.