Ombres

Cet article fait partie d'une série d'articles sur three.js. Le premier article est les fondamentaux de three.js. Si vous ne l'avez pas encore lu et que vous débutez avec three.js, vous pourriez envisager de commencer par là. L'article précédent portait sur les caméras, ce qui est important à lire avant de lire cet article, tout comme l'article précédent sur les lumières.

Les ombres sur ordinateur peuvent être un sujet complexe. Il existe diverses solutions et toutes impliquent des compromis, y compris les solutions disponibles dans three.js.

Three.js utilise par défaut des cartes d'ombres. Le fonctionnement d'une carte d'ombres est le suivant : pour chaque lumière qui projette des ombres, tous les objets marqués pour projeter des ombres sont rendus du point de vue de la lumière. **LISEZ CELA À NOUVEAU !** et laissez-le s'imprégner.

En d'autres termes, si vous avez 20 objets et 5 lumières, et que les 20 objets projettent des ombres et les 5 lumières projettent des ombres, alors toute votre scène sera dessinée 6 fois. Les 20 objets seront dessinés pour la lumière n°1, puis les 20 objets seront dessinés pour la lumière n°2, puis n°3, etc., et enfin la scène réelle sera dessinée en utilisant les données des 5 premiers rendus.

Pire encore, si vous avez une lumière ponctuelle (point light) qui projette des ombres, la scène doit être dessinée 6 fois juste pour cette lumière !

Pour ces raisons, il est courant de trouver d'autres solutions plutôt que d'avoir un tas de lumières générant toutes des ombres. Une solution courante consiste à avoir plusieurs lumières mais seulement une lumière directionnelle (directional light) générant des ombres.

Une autre solution consiste à utiliser des lightmaps (cartes d'éclairage) et/ou des ambient occlusion maps (cartes d'occlusion ambiante) pour pré-calculer les effets d'éclairage hors ligne. Cela se traduit par un éclairage statique ou des indices d'éclairage statique, mais au moins c'est rapide. Nous aborderons ces deux points dans un autre article.

Une autre solution consiste à utiliser de fausses ombres. Créez un plan, placez une texture en niveaux de gris sur le plan qui approxime une ombre, dessinez-le au-dessus du sol, sous votre objet.

Par exemple, utilisons cette texture comme fausse ombre

Nous utiliserons une partie du code de l'article précédent.

Définissons la couleur de fond sur blanc.

const scene = new THREE.Scene();
+scene.background = new THREE.Color('white');

Ensuite, nous allons configurer le même sol en damier, mais cette fois-ci en utilisant un MeshBasicMaterial car nous n'avons pas besoin d'éclairage pour le sol.

+const loader = new THREE.TextureLoader();

{
  const planeSize = 40;

-  const loader = new THREE.TextureLoader();
  const texture = loader.load('resources/images/checker.png');
  texture.wrapS = THREE.RepeatWrapping;
  texture.wrapT = THREE.RepeatWrapping;
  texture.magFilter = THREE.NearestFilter;
  const repeats = planeSize / 2;
  texture.repeat.set(repeats, repeats);

  const planeGeo = new THREE.PlaneGeometry(planeSize, planeSize);
  const planeMat = new THREE.MeshBasicMaterial({
    map: texture,
    side: THREE.DoubleSide,
  });
+  planeMat.color.setRGB(1.5, 1.5, 1.5);
  const mesh = new THREE.Mesh(planeGeo, planeMat);
  mesh.rotation.x = Math.PI * -.5;
  scene.add(mesh);
}

Notez que nous définissons la couleur sur 1.5, 1.5, 1.5. Cela multipliera les couleurs de la texture en damier par 1.5, 1.5, 1.5. Étant donné que les couleurs de la texture sont 0x808080 et 0xC0C0C0, ce qui correspond à un gris moyen et un gris clair, les multiplier par 1.5 nous donnera un damier blanc et gris clair.

Chargeons la texture d'ombre

const shadowTexture = loader.load('resources/images/roundshadow.png');

et créons un tableau pour mémoriser chaque sphère et les objets associés.

const sphereShadowBases = [];

Ensuite, nous allons créer une géométrie de sphère

const sphereRadius = 1;
const sphereWidthDivisions = 32;
const sphereHeightDivisions = 16;
const sphereGeo = new THREE.SphereGeometry(sphereRadius, sphereWidthDivisions, sphereHeightDivisions);

Et une géométrie de plan pour la fausse ombre

const planeSize = 1;
const shadowGeo = new THREE.PlaneGeometry(planeSize, planeSize);

Maintenant, nous allons créer un tas de sphères. Pour chaque sphère, nous créerons une base THREE.Object3D et nous ferons du maillage du plan d'ombre et du maillage de la sphère des enfants de la base. De cette façon, si nous déplaçons la base, la sphère et l'ombre se déplaceront. Nous devons placer l'ombre légèrement au-dessus du sol pour éviter le z-fighting. Nous définissons également depthWrite à false afin que les ombres ne s'entremêlent pas. Nous aborderons ces deux problèmes dans un autre article. L'ombre est un MeshBasicMaterial car elle n'a pas besoin d'éclairage.

Nous donnons à chaque sphère une teinte différente, puis nous enregistrons la base, le maillage de la sphère, le maillage de l'ombre et la position y initiale de chaque sphère.

const numSpheres = 15;
for (let i = 0; i < numSpheres; ++i) {
  // créer une base pour l'ombre et la sphère
  // afin qu'elles se déplacent ensemble.
  const base = new THREE.Object3D();
  scene.add(base);

  // ajouter l'ombre à la base
  // note : nous créons un nouveau matériau pour chaque sphère
  // afin de pouvoir définir la transparence du matériau de cette sphère
  // séparément.
  const shadowMat = new THREE.MeshBasicMaterial({
    map: shadowTexture,
    transparent: true,    // pour que nous puissions voir le sol
    depthWrite: false,    // pour ne pas avoir à trier
  });
  const shadowMesh = new THREE.Mesh(shadowGeo, shadowMat);
  shadowMesh.position.y = 0.001;  // pour être légèrement au-dessus du sol
  shadowMesh.rotation.x = Math.PI * -.5;
  const shadowSize = sphereRadius * 4;
  shadowMesh.scale.set(shadowSize, shadowSize, shadowSize);
  base.add(shadowMesh);

  // ajouter la sphère à la base
  const u = i / numSpheres;   // va de 0 à 1 au fur et à mesure que nous parcourons les sphères.
  const sphereMat = new THREE.MeshPhongMaterial();
  sphereMat.color.setHSL(u, 1, .75);
  const sphereMesh = new THREE.Mesh(sphereGeo, sphereMat);
  sphereMesh.position.set(0, sphereRadius + 2, 0);
  base.add(sphereMesh);

  // mémoriser les 3 plus la position y
  sphereShadowBases.push({base, sphereMesh, shadowMesh, y: sphereMesh.position.y});
}

Nous configurons 2 lumières. L'une est une HemisphereLight avec l'intensité définie à 2 pour vraiment éclaircir les choses.

{
  const skyColor = 0xB1E1FF;  // bleu clair
  const groundColor = 0xB97A20;  // orange brunâtre
  const intensity = 2;
  const light = new THREE.HemisphereLight(skyColor, groundColor, intensity);
  scene.add(light);
}

L'autre est une DirectionalLight afin que les sphères obtiennent une certaine définition

{
  const color = 0xFFFFFF;
  const intensity = 1;
  const light = new THREE.DirectionalLight(color, intensity);
  light.position.set(0, 10, 5);
  light.target.position.set(-5, 0, 0);
  scene.add(light);
  scene.add(light.target);
}

Cela rendrait tel quel, mais animons ces sphères. Pour chaque ensemble sphère, ombre, base, nous déplaçons la base dans le plan xz, nous déplaçons la sphère de haut en bas en utilisant Math.abs(Math.sin(time)) ce qui nous donne une animation rebondissante. Et nous définissons également l'opacité du matériau de l'ombre afin que, à mesure que chaque sphère monte, son ombre s'estompe.

function render(time) {
  time *= 0.001;  // convertir en secondes

  ...

  sphereShadowBases.forEach((sphereShadowBase, ndx) => {
    const {base, sphereMesh, shadowMesh, y} = sphereShadowBase;

    // u est une valeur qui va de 0 à 1 au fur et à mesure que nous parcourons les sphères
    const u = ndx / sphereShadowBases.length;

    // calculer une position pour la base. Cela déplacera
    // à la fois la sphère et son ombre
    const speed = time * .2;
    const angle = speed + u * Math.PI * 2 * (ndx % 1 ? 1 : -1);
    const radius = Math.sin(speed - ndx) * 10;
    base.position.set(Math.cos(angle) * radius, 0, Math.sin(angle) * radius);

    // yOff est une valeur qui va de 0 à 1
    const yOff = Math.abs(Math.sin(time * 2 + ndx));
    // déplacer la sphère de haut en bas
    sphereMesh.position.y = y + THREE.MathUtils.lerp(-2, 2, yOff);
    // estomper l'ombre à mesure que la sphère monte
    shadowMesh.material.opacity = THREE.MathUtils.lerp(1, .25, yOff);
  });

  ...

Et voici 15 sortes de balles rebondissantes.

Dans certaines applications, il est courant d'utiliser une ombre ronde ou ovale pour tout, mais bien sûr, vous pourriez aussi utiliser des textures d'ombre de formes différentes. Vous pourriez également donner à l'ombre un bord plus net. Un bon exemple de l'utilisation de ce type d'ombre est Animal Crossing Pocket Camp où vous pouvez voir que chaque personnage a une simple ombre ronde. C'est efficace et peu coûteux. Monument Valley semble également utiliser ce type d'ombre pour le personnage principal.

Passons maintenant aux cartes d'ombres. Il existe 3 types de lumières qui peuvent projeter des ombres : la DirectionalLight, la PointLight et la SpotLight.

Commençons par la DirectionalLight en utilisant l'exemple avec helper de l'article sur les lumières.

La première chose à faire est d'activer les ombres dans le rendu (renderer).

const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
+renderer.shadowMap.enabled = true;

Ensuite, nous devons également dire à la lumière de projeter une ombre

const light = new THREE.DirectionalLight(color, intensity);
+light.castShadow = true;

Nous devons également parcourir chaque maillage dans la scène et décider s'il doit à la fois projeter des ombres et/ou recevoir des ombres.

Faisons en sorte que le plan (le sol) reçoive uniquement les ombres, car nous ne nous soucions pas vraiment de ce qui se passe en dessous.

const mesh = new THREE.Mesh(planeGeo, planeMat);
mesh.receiveShadow = true;

Pour le cube et la sphère, faisons en sorte qu'ils reçoivent et projettent tous deux des ombres

const mesh = new THREE.Mesh(cubeGeo, cubeMat);
mesh.castShadow = true;
mesh.receiveShadow = true;

...

const mesh = new THREE.Mesh(sphereGeo, sphereMat);
mesh.castShadow = true;
mesh.receiveShadow = true;

Et ensuite, nous l'exécutons.

Que s'est-il passé ? Pourquoi des parties des ombres sont-elles manquantes ?

La raison est que les cartes d'ombres sont créées en rendant la scène du point de vue de la lumière. Dans ce cas, il y a une caméra au niveau de la DirectionalLight qui regarde sa cible. Tout comme les caméras que nous avons précédemment couvertes, la caméra d'ombre de la lumière définit une zone à l'intérieur de laquelle les ombres sont rendues. Dans l'exemple ci-dessus, cette zone est trop petite.

Afin de visualiser cette zone, nous pouvons obtenir la caméra d'ombre de la lumière et ajouter un CameraHelper à la scène.

const cameraHelper = new THREE.CameraHelper(light.shadow.camera);
scene.add(cameraHelper);

Et maintenant, vous pouvez voir la zone pour laquelle les ombres sont projetées et reçues.

Ajustez la valeur x de la cible d'avant en arrière et il devrait être assez clair que seules les ombres sont dessinées dans la zone de la caméra d'ombre de la lumière.

Nous pouvons ajuster la taille de cette boîte en ajustant la caméra d'ombre de la lumière.

Ajoutons des paramètres d'interface graphique pour ajuster la boîte de la caméra d'ombre de la lumière. Comme une DirectionalLight représente une lumière allant dans une direction parallèle, la DirectionalLight utilise une OrthographicCamera pour sa caméra d'ombre. Nous avons vu comment fonctionne une OrthographicCamera dans l'article précédent sur les caméras.

Rappelons qu'une OrthographicCamera définit sa boîte ou son frustum de visualisation par ses propriétés left, right, top, bottom, near, far, et zoom.

À nouveau, créons une classe d'aide pour lil-gui. Nous créerons une DimensionGUIHelper à laquelle nous passerons un objet et 2 propriétés. Elle présentera une propriété que lil-gui peut ajuster et, en réponse, définira les deux propriétés, l'une positive et l'autre négative. Nous pouvons l'utiliser pour définir left et right comme width, et up et down comme height.

class DimensionGUIHelper {
  constructor(obj, minProp, maxProp) {
    this.obj = obj;
    this.minProp = minProp;
    this.maxProp = maxProp;
  }
  get value() {
    return this.obj[this.maxProp] * 2;
  }
  set value(v) {
    this.obj[this.maxProp] = v /  2;
    this.obj[this.minProp] = v / -2;
  }
}

Nous utiliserons également la MinMaxGUIHelper que nous avons créée dans l'article sur les caméras pour ajuster near et far.

const gui = new GUI();
gui.addColor(new ColorGUIHelper(light, 'color'), 'value').name('color');
gui.add(light, 'intensity', 0, 2, 0.01);
+{
+  const folder = gui.addFolder('Caméra d\'ombre');
+  folder.open();
+  folder.add(new DimensionGUIHelper(light.shadow.camera, 'left', 'right'), 'value', 1, 100)
+    .name('largeur')
+    .onChange(updateCamera);
+  folder.add(new DimensionGUIHelper(light.shadow.camera, 'bottom', 'top'), 'value', 1, 100)
+    .name('hauteur')
+    .onChange(updateCamera);
+  const minMaxGUIHelper = new MinMaxGUIHelper(light.shadow.camera, 'near', 'far', 0.1);
+  folder.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('proche').onChange(updateCamera);
+  folder.add(minMaxGUIHelper, 'max', 0.1, 50, 0.1).name('loin').onChange(updateCamera);
+  folder.add(light.shadow.camera, 'zoom', 0.01, 1.5, 0.01).onChange(updateCamera);
+}

Nous demandons à l'interface graphique d'appeler notre fonction updateCamera chaque fois que quelque chose change. Écrivons cette fonction pour mettre à jour la lumière, l'helper pour la lumière, la caméra d'ombre de la lumière et l'helper affichant la caméra d'ombre de la lumière.

function updateCamera() {
  // mettre à jour le matrixWorld de la cible de la lumière car il est nécessaire pour l'helper
  light.target.updateMatrixWorld();
  helper.update();
  // mettre à jour la matrice de projection de la caméra d'ombre de la lumière
  light.shadow.camera.updateProjectionMatrix();
  // et maintenant mettre à jour l'helper caméra que nous utilisons pour afficher la caméra d'ombre de la lumière
  cameraHelper.update();
}
updateCamera();

Et maintenant que nous avons donné à la caméra d'ombre de la lumière une interface graphique, nous pouvons jouer avec les valeurs.

Définissez la largeur et la hauteur à environ 30 et vous pourrez voir que les ombres sont correctes et que les zones qui doivent être dans l'ombre pour cette scène sont entièrement couvertes.

Mais cela soulève la question : pourquoi ne pas simplement définir la largeur et la hauteur à des nombres géants pour tout couvrir ? Définissez la largeur et la hauteur à 100 et vous pourriez voir quelque chose comme ceci

Qu'est-ce qui se passe avec ces ombres basse résolution ?!

Ce problème est un autre paramètre lié aux ombres dont il faut être conscient. Les cartes d'ombres sont des textures dans lesquelles les ombres sont dessinées. Ces textures ont une taille. La zone de la caméra d'ombre que nous avons définie ci-dessus est étirée sur cette taille. Cela signifie que plus la zone que vous définissez est grande, plus vos ombres seront pixelisées.

Vous pouvez définir la résolution de la texture de la carte d'ombre en définissant light.shadow.mapSize.width et light.shadow.mapSize.height. Par défaut, elles sont de 512x512. Plus vous les augmentez, plus elles consomment de mémoire et plus elles sont lentes à calculer, vous voulez donc les définir aussi petites que possible tout en faisant fonctionner votre scène. Il en va de même pour la zone de la caméra d'ombre de la lumière. Plus elle est petite, meilleures sont les ombres, alors rendez la zone aussi petite que possible et continuez à couvrir votre scène. Sachez que la machine de chaque utilisateur a une taille de texture maximale autorisée qui est disponible sur le renderer sous la forme de renderer.capabilities.maxTextureSize.

En passant à la SpotLight, la caméra d'ombre de la lumière devient une PerspectiveCamera. Contrairement à la caméra d'ombre de la DirectionalLight où nous pouvions définir manuellement la plupart de ses paramètres, la caméra d'ombre de la SpotLight est contrôlée par la SpotLight elle-même. Le fov (champ de vision) pour l'ombre de la caméra est directement lié au paramètre angle de la SpotLight. L'aspect est défini automatiquement en fonction de la taille de la carte d'ombre.

-const light = new THREE.DirectionalLight(color, intensity);
+const light = new THREE.SpotLight(color, intensity);

et nous avons rajouté les paramètres penumbra et angle de notre article sur les lumières.

Et enfin, il y a les ombres avec une PointLight. Comme une PointLight brille dans toutes les directions, les seuls paramètres pertinents sont near et far. Sinon, l'ombre d'une PointLight est effectivement composée de 6 ombres de SpotLight , chacune pointant vers une face d'un cube autour de la lumière. Cela signifie que les ombres des PointLight sont beaucoup plus lentes car toute la scène doit être dessinée 6 fois, une pour chaque direction.

Mettons une boîte autour de notre scène pour que nous puissions voir les ombres sur les murs et le plafond. Nous définirons la propriété side du matériau sur THREE.BackSide afin de rendre l'intérieur de la boîte au lieu de l'extérieur. Comme le sol, nous la définirons pour qu'elle ne reçoive que les ombres. De plus, nous définirons la position de la boîte de manière à ce que son bas soit légèrement en dessous du sol afin que le sol et le bas de la boîte ne causent pas de z-fighting.

{
  const cubeSize = 30;
  const cubeGeo = new THREE.BoxGeometry(cubeSize, cubeSize, cubeSize);
  const cubeMat = new THREE.MeshPhongMaterial({
    color: '#CCC',
    side: THREE.BackSide,
  });
  const mesh = new THREE.Mesh(cubeGeo, cubeMat);
  mesh.receiveShadow = true;
  mesh.position.set(0, cubeSize / 2 - 0.1, 0);
  scene.add(mesh);
}

Et bien sûr, nous devons changer la lumière en PointLight.

-const light = new THREE.SpotLight(color, intensity);
+const light = new THREE.PointLight(color, intensity);

....

// pour pouvoir facilement voir où se trouve la lumière ponctuelle
+const helper = new THREE.PointLightHelper(light);
+scene.add(helper);

Utilisez les paramètres d'interface graphique position pour déplacer la lumière et vous verrez les ombres tomber sur tous les murs. Vous pouvez également ajuster les paramètres near et far et voir, tout comme pour les autres ombres, que lorsque les objets sont plus proches que near, ils ne reçoivent plus d'ombre et lorsqu'ils sont plus loin que far, ils sont toujours dans l'ombre.