H2 - P2021

Développement web

Cours 21

WebGL

Avec Three.js

Qu'est-ce que le WebGL ?

API javascript permettant de faire des rendus 2D et 3D dans le browser en utilisant la puissance de la carte graphique

En réalité, le WebGL permet de dessiner une grande quantité de triangle très rapidement

  • Nécessite un canvas
  • WebGL utilise l'OpenGL
  • OpenGL utilise la carte graphique pour faire des calculs, pour gérer des géométries, des textures, des matrices, etc.
  • Accélération matérielle
  • Permet aussi bien de faire de la 3D que de la 2D

À quoi sert la carte graphique ?

  • Si un processeur devait traiter un rendu, il le ferait pixel par pixel
  • Une image de 800x600 comprend donc 480 000 pixels à traiter un par un
  • Cela lui prendrait trop de temps et on ne pourrait avoir une animation fluide (60fps)
  • Quand la carte graphique traite le rendu, elle fait des milliers de calculs en parallèle. Cela lui prend donc moins de temps.

Compatibilité

CanIUse

Peut nécessiter une carte graphique puissante
(Attention aux smartphones)

Démonstrations

Composition

  • Un rendu 3D va partir d'une scène (pouvant contenir des objets) et d'une caméra pour en déduire une image 2D
  • Les objets sont composés de géométries, elles-mêmes composée de faces, sur lesquelles sont appliquées des matières
  • La caméra possède des paramètres (angle de vue, orthographie ou perspective, distance min et max, zoom, etc.)

On parle de projection

Ces projections et calculs sont faits dans des programmes appelé shaders (en GLSL) et envoyés à la carte graphique

Développer ces programmes, créer des géométries et effectuer les projections prendrait trop de temps. Nous allons utiliser la librairie Three.js

Three.js

Auteur: Ricardo Cabello (Mr. Doob)
twitter, site

Documentation

Dans un nouveau dossier

  • Téléchargez Three.js
  • Créez un fichier script.js
  • Créez un fichier index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>WebGL - Three.js</title>
</head>
<body>
    <script src="three.min.js"></script>
    <script src="script.js"></script>
</body>
</html>
                    

Ce cours a été réalisé avec la version 92 de Three.js

Pour fonctionner, nous allons avoir besoin d'

  • une scène
  • un objet dans cette scène
  • une caméra
  • un renderer qui fera le rendu et le mettra dans un canvas
  • une fonction qui s'executera à chaque frame pour faire des rendus

Scene

La scène a pour simple objectif de contenir les différents objets


/**
 * Scene
 */
const scene = new THREE.Scene()
                    

Camera

Il existe plusieurs types de caméras, mais deux sont à retenir

Nous allons utiliser une caméra avec perspective dont les paramètres sont les suivants

  • Field of view : angle de vue en degrés
  • Aspect : rapport largeur par hauteur

let windowWidth = window.innerWidth
let windowHeight = window.innerHeight

/**
 * Camera
 */
const camera = new THREE.PerspectiveCamera(75, windowWidth / windowHeight)
camera.position.z = 3
scene.add(camera)
                    

Object (Ou Mesh)

Un objet 3D est appelé Mesh et se compose d'une géométrie et d'une matière

Géométrie

  • Une géométrie se compose de faces (ou triangles)
  • Une face se compose de vertices (ou vertex, ou points)

Three.js possède de nombreuses géométries pré-faites

Faisons une Box


const geometry = new THREE.BoxGeometry(1, 1, 1)
                    

Matière

La matière va définir quelle doit être la couleur à appliquer à chaque pixel visible de la géométrie

Cette couleur peut varier selon un code hexadecimal, des lumières, une image, etc.

Three.js possède de nombreuses matières, mais nous allons commencer avec la plus simple MeshBasicMaterial


const material = new THREE.MeshBasicMaterial({ color: 0xff0000 })
                    

Et enfin, créons une Mesh à partir de la géométrie et du Material que nous rajoutons à la Scene


const mesh = new THREE.Mesh(geometry, material)
scene.add(mesh)
                    

Tout en même temps


/**
 * Object
 */
const geometry = new THREE.BoxGeometry(1, 1, 1)
const material = new THREE.MeshBasicMaterial({ color: 0xff0000 })
const mesh = new THREE.Mesh(geometry, material)
scene.add(mesh)
                    

Renderer

Enfin, nous souhaitons récupérer ce que voit la caméra et l'afficher dans un canvas

C'est le role du renderer qui va aussi créer un canvas qu'il suffira d'ajouter au body


/**
 * Renderer
 */
const renderer = new THREE.WebGLRenderer()
renderer.setSize(windowWidth, windowHeight)
document.body.appendChild(renderer.domElement)
renderer.render(scene, camera)
                    

Tester

Ouvrez index.html dans votre browser

Vous devriez voir un carré rouge sur fond noir

Ceci est votre premier rendu WebGL avec Three.js

CSS

Le canvas ne prend pas tout le viewport. C'est à cause des marges sur le body.

Rajoutez le CSS suivant


canvas
{
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
}
                    

Animation

Nous souhaiterions faire tourner le cube et faire bouger la caméra

Comme pour l'animation de canvas, à chaque frame, nous allons modifier les coordonnées des objets et faire un rendu

Nous allons utiliser requestAnimationFrame


/**
 * Loop
 */
const loop = () =>
{
    window.requestAnimationFrame(loop)

    // Render
    renderer.render(scene, camera)
}

loop()
                    

Nous allons faire bouger la caméra en fonction de la souris

Pour cela, nous allons écouter l'événement mousemove et enregistrer la position de la souris dans un objet mouse qui nous servira plus tard


/**
 * Mouse
 */
const mouse = { x: 0.5, y: 0.5 }
window.addEventListener('mousemove', (event) =>
{
    mouse.x = event.clientX / windowWidth - 0.5
    mouse.y = event.clientY / windowHeight - 0.5
})
                    

Les Meshes peuvent être manipulées à l'aide des propriétés position, rotation et scale

Chacune de ces propriétés s'appelle un Vector3 et possède les propriétés x, y et z correspondant aux axes

La caméra peut aussi être manipulée ainsi

Position


mesh.position.x = mouse.x * 3
mesh.position.y = - mouse.y * 3
                    

Rotation


mesh.rotation.x = mouse.y * 3
mesh.rotation.y = mouse.x * 3
                    

Scale


mesh.scale.x = mouse.x * 3
mesh.scale.y = mouse.y * 3
                    

Rotation permanente sur la mesh et position de la caméra par rapport à la souris

LookAt permet de diriger l'objet vers un vecteur


/**
 * Loop
 */
const loop = () =>
{
    window.requestAnimationFrame(loop)

    // Update mesh
    mesh.rotation.y += 0.005

    // Update camera
    camera.position.x = mouse.x * 3
    camera.position.y = - mouse.y * 3
    camera.lookAt(mesh.position)

    // Render
    renderer.render(scene, camera)
}

loop()
                    

Construisez une maison !

Three.js permet de créer des objets vides pouvant servir de container

Cela permet de les transformer plus facilement


const house = new THREE.Object3D()
scene.add(house)

const walls = new THREE.Mesh(
    new THREE.BoxGeometry(1.5, 1, 1.5),
    new THREE.MeshBasicMaterial({ color: 0xffcc99 })
)
house.add(walls)
                    

Maison complète


/**
 * House
 */
const house = new THREE.Object3D()
scene.add(house)

const floor = new THREE.Mesh(
    new THREE.PlaneGeometry(4, 4, 1, 1),
    new THREE.MeshBasicMaterial({ color: 0x66bb66 })
)
floor.rotation.x = - Math.PI * 0.5
floor.position.y = - 0.5
house.add(floor)

const walls = new THREE.Mesh(
    new THREE.BoxGeometry(1.5, 1, 1.5),
    new THREE.MeshBasicMaterial({ color: 0xffcc99 })
)
house.add(walls)

const roof = new THREE.Mesh(
    new THREE.ConeGeometry(1.2, 0.6, 0.04),
    new THREE.MeshBasicMaterial({ color: 0x885522 })
)
roof.position.y += 0.8
roof.rotation.y += Math.PI * 0.25
house.add(roof)

const door = new THREE.Mesh(
    new THREE.BoxGeometry(0.02, 0.4, 0.2),
    new THREE.MeshBasicMaterial({ color: 0xff8866 })
)
door.position.x = - 0.76
door.position.y = - 0.3
house.add(door)

const bush1 = new THREE.Mesh(
    new THREE.SphereGeometry(0.1, 0.32, 0.32),
    new THREE.MeshBasicMaterial({ color: 0x228833 })
)
bush1.position.x = - 0.8
bush1.position.z = 0.2
bush1.position.y = - 0.45
house.add(bush1)

const bush2 = new THREE.Mesh(
    new THREE.SphereGeometry(0.08, 32, 32),
    new THREE.MeshBasicMaterial({ color: 0x228833 })
)
bush2.position.x = - 0.8
bush2.position.z = - 0.2
bush2.position.y = - 0.48
house.add(bush2)
                    

Lumières

La gestion des lumières est très gourmande en performance

Pour fonctionner, nous devons utiliser des matières réagissant à la lumière et rajouter des lumières

Matière

Three.js supporte plusieurs types de matières

MeshBasicMaterial Couleur ou texture ne réagissant pas à la lumière
MeshLambertMaterial Couleur ou texture réagissant à la lumière avec un rendu moyen
MeshPhongMaterial Couleur ou texture réagissant à la lumière avec un rendu de meilleure qualité mais moins performant
MeshStandardMaterial Comme MeshPhongMaterial, mais avec des paramètres physically based

Physically based correspond à une volonté de baser les paramètres d'une entité sur des valeurs physique proche de la réalité

Exemples: gravité, force du vent, composition d'une matière, etc.

Nous allons remplacer MeshBasicMaterial par MeshStandardMaterial et rajouter du metalness et du rougness en paramètres


/* ... */
    new THREE.MeshStandardMaterial({ color: 0x66bb66, metalness: 0.3, roughness: 0.8 })
/* ... */
                    

Nos objets ont disparu, mais c'est normal

MeshStandardMaterial se basant sur la lumière, il faut rajouter ces lumières

Three.js supporte plusieurs types de lumières ayant des zones d'action différentes

Nous allons utiliser une PointLight que nous allons mettre au dessus de la porte, une DirectionalLight pour imiter l'illumitation du soleil et une AmbientLight pour éclairer les faces cachées


/**
 * Lights
 */
const doorLight = new THREE.PointLight()
doorLight.position.x = -1.02
doorLight.position.y = 0
doorLight.position.z = 0
house.add(doorLight)

const ambientLight = new THREE.AmbientLight(0x555555)
scene.add(ambientLight)

const sunLight = new THREE.DirectionalLight(0xffffff, 0.6)
sunLight.position.x = 1
sunLight.position.y = 1
sunLight.position.z = 1
house.add(sunLight)
                    

Ombres

Les ombres ont toujours été un challenge pour la 3d temps réel. Même aujourd'hui, avec les cartes graphiques actuelles, les développeurs doivent faire preuve d'ingéniosité pour afficher des ombres

Three.js intègre une gestion basique et peu réaliste des ombres

Nous devons avertir le renderer que des ombres vont être utilisées


renderer.shadowMap.enabled = true
                    

Nous devons ensuite avertir si chaque objet (Mesh) doit générer une ombre (castShadow) et/ou recevoir des ombres (receiveShadow)


walls.castShadow = true
walls.receiveShadow = true
                    

Il est important de n'activer les ombres que sur les objets le nécessitant

Nous devons enfin avertir chaque lumière si elle doit générer des ombres


doorLight.castShadow = true

/* ... */

sunLight.castShadow = true
sunLight.shadow.camera.top = 1.20
sunLight.shadow.camera.right = 1.20
sunLight.shadow.camera.bottom = -1.20
sunLight.shadow.camera.left = -1.20
                    

Notre DirectionalLight nécessite quelques autres paramètres afin de fonctionner

Textures

Pour plus de réalisme, il vaut mieux utiliser des textures

Texture est l'objet permettant de créer soi-même une texture dans Three.js, mais TextureLoader automatise le processus


/**
 * Textures
 */
const textureLoader = new THREE.TextureLoader()

const grassTexture = textureLoader.load('grass.jpg')
                    

Une fois la texture créée, il suffit de l'ajouter dans l'attribut map de MeshStandardMaterial


const floor = new THREE.Mesh(
    new THREE.PlaneGeometry(4, 4, 1, 1),
    new THREE.MeshStandardMaterial({ map: grassTexture, metalness: 0.3, roughness: 0.8 })
)
                    

Attention

Le chargement de textures ne fonctionnera pas en local sur Chrome sans lancer un serveur pour des raisons de sécurité

Il est possible de manipuler la texture, par exemple en lui indiquant de se répéter un certain nombre de fois


grassTexture.wrapS = THREE.RepeatWrapping
grassTexture.wrapT = THREE.RepeatWrapping
grassTexture.repeat.set(4, 4)
                    

Resize

Actuellement, si vous redimensionnez la fenêtre, le canvas conserve la même taille

Nous devons écouter l'événement de resize et mettre à jour la caméra et le renderer


/**
 * Resize
 */
window.addEventListener('resize', () =>
{
    // Save width and height
    windowWidth = window.innerWidth
    windowHeight = window.innerHeight

    // Update camera
    camera.aspect = windowWidth / windowHeight
    camera.updateProjectionMatrix()

    // Update renderer
    renderer.setSize(windowWidth, windowHeight)
})
                    

Code final


/**
 * Scene
 */
const scene = new THREE.Scene()

/**
 * Camera
 */
let windowWidth = window.innerWidth
let windowHeight = window.innerHeight

const camera = new THREE.PerspectiveCamera(70, windowWidth / windowHeight)
camera.position.z = 3
scene.add(camera)

/**
 * Renderer
 */
const renderer = new THREE.WebGLRenderer()
renderer.setSize(windowWidth, windowHeight)
renderer.shadowMap.enabled = true
document.body.appendChild(renderer.domElement)
renderer.render(scene, camera)

/**
 * Mouse
 */
const mouse = { x: 0.5, y: 0.5 }
window.addEventListener('mousemove', () =>
{
    mouse.x = event.clientX / windowWidth - 0.5
    mouse.y = event.clientY / windowHeight - 0.5
})

/**
 * Textures
 */
const textureLoader = new THREE.TextureLoader()

const grassTexture = textureLoader.load('grass.jpg')
grassTexture.wrapS = THREE.RepeatWrapping
grassTexture.wrapT = THREE.RepeatWrapping
grassTexture.repeat.set(4, 4)

/**
 * House
 */
const house = new THREE.Object3D()
scene.add(house)

const floor = new THREE.Mesh(
    new THREE.PlaneGeometry(4, 4, 1, 1),
    new THREE.MeshStandardMaterial({ map: grassTexture, metalness: 0.3, roughness: 0.8 })
)
floor.rotation.x = - Math.PI * 0.5
floor.position.y = - 0.5
floor.castShadow = true
floor.receiveShadow = true
house.add(floor)

const walls = new THREE.Mesh(
    new THREE.BoxGeometry(1.5, 1, 1.5),
    new THREE.MeshStandardMaterial({ color: 0xffcc99, metalness: 0.3, roughness: 0.8 })
)
walls.castShadow = true
walls.receiveShadow = true
house.add(walls)

const roof = new THREE.Mesh(
    new THREE.ConeGeometry(1.2, 0.6, 0.04),
    new THREE.MeshStandardMaterial({ color: 0x885522, metalness: 0.3, roughness: 0.8 })
)
roof.position.y += 0.8
roof.rotation.y += Math.PI * 0.25
roof.castShadow = true
roof.receiveShadow = true
house.add(roof)

const door = new THREE.Mesh(
    new THREE.BoxGeometry(0.02, 0.4, 0.2),
    new THREE.MeshStandardMaterial({ color: 0xff8866, metalness: 0.3, roughness: 0.8 })
)
door.position.x = - 0.76
door.position.y = - 0.3
door.castShadow = true
door.receiveShadow = true
house.add(door)

const bush1 = new THREE.Mesh(
    new THREE.SphereGeometry(0.1, 0.32, 0.32),
    new THREE.MeshStandardMaterial({ color: 0x228833, metalness: 0.3, roughness: 0.8 })
)
bush1.position.x = - 0.8
bush1.position.z = 0.2
bush1.position.y = - 0.45
bush1.castShadow = true
bush1.receiveShadow = true
house.add(bush1)

const bush2 = new THREE.Mesh(
    new THREE.SphereGeometry(0.08, 32, 32),
    new THREE.MeshStandardMaterial({ color: 0x228833, metalness: 0.3, roughness: 0.8 })
)
bush2.position.x = - 0.8
bush2.position.z = - 0.2
bush2.position.y = - 0.48
bush2.castShadow = true
bush2.receiveShadow = true
house.add(bush2)

/**
 * Lights
 */
const doorLight = new THREE.PointLight()
doorLight.position.x = -1.02
doorLight.position.y = 0
doorLight.position.z = 0
doorLight.castShadow = true
house.add(doorLight)

const ambientLight = new THREE.AmbientLight(0x555555)
scene.add(ambientLight)

const sunLight = new THREE.DirectionalLight(0xffffff, 0.6)
sunLight.position.x = 1
sunLight.position.y = 1
sunLight.position.z = 1
sunLight.castShadow = true
sunLight.shadow.camera.top = 1.20
sunLight.shadow.camera.right = 1.20
sunLight.shadow.camera.bottom = -1.20
sunLight.shadow.camera.left = -1.20
house.add(sunLight)

/**
 * Resize
 */
window.addEventListener('resize', () =>
{
    windowWidth = window.innerWidth
    windowHeight = window.innerHeight

    camera.aspect = windowWidth / windowHeight
    camera.updateProjectionMatrix()

    renderer.setSize(windowWidth, windowHeight)
})

/**
 * Loop
 */
const loop = () =>
{
    window.requestAnimationFrame(loop)

    // Update house
    house.rotation.y += 0.01
    
    // Update camera
    camera.position.x = mouse.x * 3
    camera.position.y = - mouse.y * 3
    camera.lookAt(new THREE.Vector3())

    // Render
    renderer.render(scene, camera)
}

loop()
                    

Aller plus loin

Shaders

Les shaders sont ces fameux programmes qui sont envoyés à la carte graphique et qui permettent de transformer notre scène composée d'objets en un rendu 2D

Il existe deux types de shaders vertex et fragment

Vertex

Le vertex shader est en charge des vertices

Il va permettre d'appliquer une transformation afin d'avoir un effet tel qu'une ondulation pour imiter des vagues

Fragment

Le fragment shader est en charge des pixels visibles de la géométrie

Le programme va être exécuté pour chacun d'entre eux et va permettre de modifier la couleur

Three.js possède une matière appelée ShaderMaterial permettant d'appliquer des shaders sur une Mesh

Ressources

Particules

Les particules sont très bien gérées en WebGL

Il est possible d'en afficher des millions sans souci de performance

Three.js les gère avec Points et PointsMaterial

Charger des modèles 3D

Three.js permet de charger des modèles 3D conçu dans des logiciels 3D au format .obj

Pour cela, il faut utiliser OBJLoader

Physique

Si vous souhaitez rajouter de la physique dans votre univers 3D, il faut utiliser un moteur de collision

Transpiler le JS

Nous avons utilisé des features de JS non compatible avec certains anciens navigateur

L'action de rendre compatible notre code avec ces anciennes versions s'appelle du transpilage

C'est ce que permet de faire Babel que vous retrouverez dans ce template Gulp