Textures

Cet article fait partie d'une série d'articles sur three.js. Le premier article concernait les bases de three.js. L'article précédent expliquait comment se préparer pour cet article. Si vous ne l'avez pas encore lu, vous pourriez vouloir commencer par là.

Les textures sont un sujet assez vaste dans Three.js et je ne suis pas sûr à 100% du niveau auquel les expliquer, mais je vais essayer. Il y a de nombreux sujets et beaucoup d'entre eux sont interdépendants, il est donc difficile d'expliquer tout en une seule fois. Voici une table des matières rapide pour cet article.

Bonjour la texture

Les textures sont généralement des images qui sont le plus souvent créées dans un programme tiers comme Photoshop ou GIMP. Par exemple, mettons cette image sur un cube.

Nous allons modifier un de nos premiers exemples. Tout ce que nous avons à faire est de créer un TextureLoader. Appelez sa méthode load avec l'URL d'une image et définissez la propriété map du matériau sur le résultat au lieu de définir sa color.

+const loader = new THREE.TextureLoader();
+const texture = loader.load( 'resources/images/wall.jpg' );
+texture.colorSpace = THREE.SRGBColorSpace;

const material = new THREE.MeshBasicMaterial({
-  color: 0xFF8844,
+  map: texture,
});

Notez que nous utilisons MeshBasicMaterial, donc pas besoin de lumières.

6 textures, une différente sur chaque face d'un cube

Que diriez-vous de 6 textures, une sur chaque face d'un cube ?

Nous créons simplement 6 matériaux et les passons sous forme de tableau lorsque nous créons le Mesh

const loader = new THREE.TextureLoader();
-const texture = loader.load( 'resources/images/wall.jpg' );
-texture.colorSpace = THREE.SRGBColorSpace;

-const material = new THREE.MeshBasicMaterial({
-  map: texture,
-});
+const materials = [
+  new THREE.MeshBasicMaterial({map: loadColorTexture('resources/images/flower-1.jpg')}),
+  new THREE.MeshBasicMaterial({map: loadColorTexture('resources/images/flower-2.jpg')}),
+  new THREE.MeshBasicMaterial({map: loadColorTexture('resources/images/flower-3.jpg')}),
+  new THREE.MeshBasicMaterial({map: loadColorTexture('resources/images/flower-4.jpg')}),
+  new THREE.MeshBasicMaterial({map: loadColorTexture('resources/images/flower-5.jpg')}),
+  new THREE.MeshBasicMaterial({map: loadColorTexture('resources/images/flower-6.jpg')}),
+];
-const cube = new THREE.Mesh(geometry, material);
+const cube = new THREE.Mesh(geometry, materials);

+function loadColorTexture( path ) {
+  const texture = loader.load( path );
+  texture.colorSpace = THREE.SRGBColorSpace;
+  return texture;
+}

Ça marche !

Il convient de noter cependant que tous les types de géométrie ne supportent pas plusieurs matériaux. BoxGeometry peut utiliser 6 matériaux, un pour chaque face. ConeGeometry peut utiliser 2 matériaux, un pour le fond et un pour le cône. CylinderGeometry peut utiliser 3 matériaux : fond, haut et côté. Dans d'autres cas, vous devrez construire ou charger une géométrie personnalisée et/ou modifier les coordonnées de texture.

Il est beaucoup plus courant dans d'autres moteurs 3D et beaucoup plus performant d'utiliser un atlas de textures si vous souhaitez autoriser plusieurs images sur une seule géométrie. Un atlas de textures est un endroit où vous placez plusieurs images dans une seule texture et utilisez ensuite les coordonnées de texture sur les sommets de votre géométrie pour sélectionner quelles parties d'une texture sont utilisées sur chaque triangle de votre géométrie.

Que sont les coordonnées de texture ? Ce sont des données ajoutées à chaque sommet d'une pièce de géométrie qui spécifient quelle partie de la texture correspond à ce sommet spécifique. Nous les aborderons lorsque nous commencerons à construire une géométrie personnalisée.

Chargement des textures

La manière simple

La plupart du code sur ce site utilise la méthode la plus simple pour charger des textures. Nous créons un TextureLoader, puis appelons sa méthode load. Cela renvoie un objet Texture.

const texture = loader.load('resources/images/flower-1.jpg');

Il est important de noter qu'en utilisant cette méthode, notre texture sera transparente jusqu'à ce que l'image soit chargée de manière asynchrone par three.js, moment auquel elle mettra à jour la texture avec l'image téléchargée.

Cela présente le grand avantage de ne pas avoir à attendre le chargement de la texture et notre page commencera à s'afficher immédiatement. C'est probablement acceptable pour un grand nombre de cas d'utilisation mais si nous le souhaitons, nous pouvons demander à three.js de nous informer lorsque la texture a fini de se télécharger.

Attendre le chargement d'une texture

Pour attendre le chargement d'une texture, la méthode load du chargeur de textures prend un rappel qui sera appelé lorsque la texture aura fini de se charger. En reprenant notre premier exemple, nous pouvons attendre le chargement de la texture avant de créer notre Mesh et de l'ajouter à la scène comme ceci

const loader = new THREE.TextureLoader();
loader.load('resources/images/wall.jpg', (texture) => {
  texture.colorSpace = THREE.SRGBColorSpace;
  const material = new THREE.MeshBasicMaterial({
    map: texture,
  });
  const cube = new THREE.Mesh(geometry, material);
  scene.add(cube);
  cubes.push(cube);  // add to our list of cubes to rotate
});

À moins que vous ne vidiez le cache de votre navigateur et que vous ayez une connexion lente, il est peu probable que vous voyiez une différence, mais soyez assuré qu'elle attend le chargement de la texture.

Attendre le chargement de plusieurs textures

Pour attendre que toutes les textures soient chargées, vous pouvez utiliser un LoadingManager. Créez-en un et passez-le au TextureLoader, puis définissez sa propriété onLoad sur un rappel.

+const loadManager = new THREE.LoadingManager();
*const loader = new THREE.TextureLoader(loadManager);

const materials = [
  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-1.jpg')}),
  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-2.jpg')}),
  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-3.jpg')}),
  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-4.jpg')}),
  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-5.jpg')}),
  new THREE.MeshBasicMaterial({map: loader.load('resources/images/flower-6.jpg')}),
];

+loadManager.onLoad = () => {
+  const cube = new THREE.Mesh(geometry, materials);
+  scene.add(cube);
+  cubes.push(cube);  // add to our list of cubes to rotate
+};

Le LoadingManager a également une propriété onProgress que nous pouvons définir sur un autre rappel pour afficher un indicateur de progression.

Nous allons d'abord ajouter une barre de progression en HTML

<body>
  <canvas id="c"></canvas>
+  <div id="loading">
+    <div class="progress"><div class="progressbar"></div></div>
+  </div>
</body>

et le CSS associé

#loading {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    display: flex;
    justify-content: center;
    align-items: center;
}
#loading .progress {
    margin: 1.5em;
    border: 1px solid white;
    width: 50vw;
}
#loading .progressbar {
    margin: 2px;
    background: white;
    height: 1em;
    transform-origin: top left;
    transform: scaleX(0);
}

Ensuite, dans le code, nous mettrons à jour l'échelle de la progressbar dans notre rappel onProgress. Il est appelé avec l'URL du dernier élément chargé, le nombre d'éléments chargés jusqu'à présent et le nombre total d'éléments à charger.

+const loadingElem = document.querySelector('#loading');
+const progressBarElem = loadingElem.querySelector('.progressbar');

loadManager.onLoad = () => {
+  loadingElem.style.display = 'none';
  const cube = new THREE.Mesh(geometry, materials);
  scene.add(cube);
  cubes.push(cube);  // add to our list of cubes to rotate
};

+loadManager.onProgress = (urlOfLastItemLoaded, itemsLoaded, itemsTotal) => {
+  const progress = itemsLoaded / itemsTotal;
+  progressBarElem.style.transform = `scaleX(${progress})`;
+};

À moins que vous ne vidiez votre cache et que vous ayez une connexion lente, il est possible que vous ne voyiez pas la barre de chargement.

Charger des textures depuis d'autres origines

Pour utiliser des images provenant d'autres serveurs, ces serveurs doivent envoyer les en-têtes corrects. S'ils ne le font pas, vous ne pouvez pas utiliser les images dans three.js et vous obtiendrez une erreur. Si vous gérez le serveur fournissant les images, assurez-vous qu'il envoie les en-têtes corrects. Si vous ne contrôlez pas le serveur hébergeant les images et qu'il n'envoie pas les en-têtes d'autorisation, vous ne pouvez pas utiliser les images de ce serveur.

Par exemple, imgur, flickr et github envoient tous des en-têtes vous permettant d'utiliser les images hébergées sur leurs serveurs dans three.js. La plupart des autres sites web ne le font pas.

Utilisation de la mémoire

Les textures sont souvent la partie d'une application three.js qui utilise le plus de mémoire. Il est important de comprendre qu'en général, les textures prennent largeur * hauteur * 4 * 1.33 octets de mémoire.

Notez que cela ne dit rien sur la compression. Je peux créer une image .jpg et régler sa compression très élevée. Par exemple, disons que je créais une scène d'une maison. À l'intérieur de la maison, il y a une table et je décide de mettre cette texture de bois sur la surface supérieure de la table

Cette image ne fait que 157k, elle se téléchargera donc relativement rapidement, mais sa taille est en réalité de 3024 x 3761 pixels. En suivant l'équation ci-dessus, cela donne

3024 * 3761 * 4 * 1.33 = 60505764.5

Cette image prendra 60 MÉGAOCTETS DE MÉMOIRE ! dans three.js. Quelques textures comme celle-là et vous serez à court de mémoire.

Je soulève ce point car il est important de savoir que l'utilisation des textures a un coût caché. Pour que three.js puisse utiliser la texture, il doit la transmettre au GPU, et le GPU en général nécessite que les données de la texture soient décompressées.

La morale de l'histoire est de rendre vos textures petites en dimensions, pas seulement petites en taille de fichier. Petite taille de fichier = téléchargement rapide. Petites dimensions = prend moins de mémoire. Quelle taille devraient-elles avoir ? Aussi petites que possible tout en conservant l'apparence dont vous avez besoin.

JPG vs PNG

C'est à peu près la même chose qu'en HTML classique : les JPG ont une compression avec perte, les PNG ont une compression sans perte, donc les PNG sont généralement plus lents à télécharger. Mais les PNG supportent la transparence. Les PNG sont également probablement le format approprié pour les données non-image comme les normal maps et d'autres types de maps non-image que nous verrons plus tard.

Il est important de se rappeler qu'un JPG n'utilise pas moins de mémoire qu'un PNG dans WebGL. Voir ci-dessus.

Filtrage et Mips

Appliquons cette texture 16x16

À un cube

Dessinons ce cube très petit

Hmmm, je suppose que c'est difficile à voir. Agrandissons ce tout petit cube

Comment le GPU sait-il quelles couleurs donner à chaque pixel qu'il dessine pour le petit cube ? Que se passerait-il si le cube était si petit qu'il ne fasse qu'un ou deux pixels ?

C'est à cela que sert le filtrage.

Si c'était Photoshop, Photoshop ferait la moyenne de presque tous les pixels pour déterminer la couleur à donner à ces 1 ou 2 pixels. Ce serait une opération très lente. Les GPU résolvent ce problème en utilisant les mipmaps.

Les mips sont des copies de la texture, chacune faisant la moitié de la largeur et la moitié de la hauteur du mip précédent, où les pixels ont été mélangés pour créer le mip suivant plus petit. Les mips sont créés jusqu'à ce que l'on arrive à un mip de 1x1 pixel. Pour l'image ci-dessus, tous les mips ressembleraient à ceci

Maintenant, lorsque le cube est dessiné si petit qu'il ne fait qu'un ou deux pixels, le GPU peut choisir d'utiliser uniquement le mip le plus petit ou le mip juste avant le plus petit pour décider de la couleur à donner au petit cube.

Dans three.js, vous pouvez choisir ce qui se passe à la fois lorsque la texture est dessinée plus grande que sa taille d'origine et ce qui se passe lorsqu'elle est dessinée plus petite que sa taille d'origine.

Pour définir le filtre lorsque la texture est dessinée plus grande que sa taille d'origine, vous définissez la propriété texture.magFilter sur THREE.NearestFilter ou THREE.LinearFilter. NearestFilter signifie simplement choisir le pixel unique le plus proche de la texture d'origine. Avec une texture basse résolution, cela donne un aspect très pixélisé comme dans Minecraft.

LinearFilter signifie choisir les 4 pixels de la texture qui sont les plus proches de l'endroit où nous devrions choisir une couleur et les mélanger dans les proportions appropriées par rapport à la distance entre le point réel et chacun des 4 pixels.

Plus proche
Linéaire

Pour définir le filtre lorsque la texture est dessinée plus petite que sa taille d'origine, vous définissez la propriété texture.minFilter sur l'une des 6 valeurs suivantes.

  • THREE.NearestFilter

    identique à ci-dessus, choisir le pixel le plus proche dans la texture

  • THREE.LinearFilter

    identique à ci-dessus, choisir 4 pixels de la texture et les mélanger

  • THREE.NearestMipmapNearestFilter

    choisir le mip approprié puis choisir un pixel

  • THREE.NearestMipmapLinearFilter

    choisir 2 mips, choisir un pixel de chaque, mélanger les 2 pixels

  • THREE.LinearMipmapNearestFilter

    choisir le mip approprié puis choisir 4 pixels et les mélanger

  • THREE.LinearMipmapLinearFilter

    choisir 2 mips, choisir 4 pixels de chaque et mélanger les 8 en 1 pixel

Voici un exemple montrant les 6 paramètres

cliquer pour
changer la
texture
plus proche
linéaire
plus proche
mipmap
plus proche
plus proche
mipmap
linéaire
linéaire
mipmap
plus proche
linéaire
mipmap
linéaire

Une chose à remarquer est que le coin supérieur gauche et le milieu supérieur utilisant NearestFilter et LinearFilter n'utilisent pas les mips. De ce fait, ils scintillent au loin car le GPU sélectionne des pixels de la texture d'origine. À gauche, un seul pixel est choisi et au milieu, 4 sont choisis et mélangés, mais ce n'est pas suffisant pour obtenir une bonne couleur représentative. Les 4 autres bandes s'en sortent mieux, celle en bas à droite, LinearMipmapLinearFilter, étant la meilleure.

Si vous cliquez sur l'image ci-dessus, elle basculera entre la texture que nous avons utilisée ci-dessus et une texture où chaque niveau de mip est d'une couleur différente.

Cela rend plus clair ce qui se passe. Vous pouvez voir en haut à gauche et au milieu supérieur que le premier mip est utilisé jusqu'au loin. En haut à droite et au milieu inférieur, vous pouvez clairement voir où un mip différent est utilisé.

En revenant à la texture d'origine, vous pouvez voir que celle en bas à droite est la plus lisse, de la plus haute qualité. Vous pourriez vous demander pourquoi ne pas toujours utiliser ce mode. La raison la plus évidente est que parfois vous voulez que les choses soient pixélisées pour un look rétro ou pour une autre raison. La raison suivante la plus courante est que lire 8 pixels et les mélanger est plus lent que de lire 1 pixel et de le mélanger. Bien qu'il soit peu probable qu'une seule texture fasse la différence entre rapide et lent, à mesure que nous progresserons dans ces articles, nous aurons finalement des matériaux qui utilisent 4 ou 5 textures en même temps. 4 textures * 8 pixels par texture, c'est rechercher 32 pixels pour chaque pixel rendu. Cela peut être particulièrement important à considérer sur les appareils mobiles.

Répétition, décalage, rotation, habillage d'une texture

Les textures ont des paramètres pour la répétition, le décalage et la rotation d'une texture.

Par défaut, les textures dans three.js ne se répètent pas. Pour définir si une texture se répète ou non, il existe 2 propriétés : wrapS pour l'habillage horizontal et wrapT pour l'habillage vertical.

Ils peuvent être définis sur l'une des valeurs suivantes :

  • THREE.ClampToEdgeWrapping

    le dernier pixel sur chaque bord est répété indéfiniment

  • THREE.RepeatWrapping

    la texture est répétée

  • THREE.MirroredRepeatWrapping

    la texture est mise en miroir et répétée

Par exemple, pour activer l'habillage dans les deux directions :

someTexture.wrapS = THREE.RepeatWrapping;
someTexture.wrapT = THREE.RepeatWrapping;

La répétition est définie avec la propriété [repeat] repeat.

const timesToRepeatHorizontally = 4;
const timesToRepeatVertically = 2;
someTexture.repeat.set(timesToRepeatHorizontally, timesToRepeatVertically);

Le décalage de la texture peut être effectué en définissant la propriété offset. Les textures sont décalées avec des unités où 1 unité = 1 taille de texture. Autrement dit, 0 = pas de décalage et 1 = décalage d'une quantité de texture complète.

const xOffset = .5;   // offset by half the texture
const yOffset = .25;  // offset by 1/4 the texture
someTexture.offset.set(xOffset, yOffset);

La rotation de la texture peut être définie en définissant la propriété rotation en radians ainsi que la propriété center pour choisir le centre de rotation. Elle est par défaut à 0,0, ce qui correspond à une rotation depuis le coin inférieur gauche. Comme pour le décalage, ces unités sont en taille de texture, donc les définir à .5, .5 effectuerait une rotation autour du centre de la texture.

someTexture.center.set(.5, .5);
someTexture.rotation = THREE.MathUtils.degToRad(45);

Modifions l'exemple du haut ci-dessus pour jouer avec ces valeurs

Tout d'abord, nous allons conserver une référence à la texture afin de pouvoir la manipuler

+const texture = loader.load('resources/images/wall.jpg');
const material = new THREE.MeshBasicMaterial({
-  map: loader.load('resources/images/wall.jpg');
+  map: texture,
});

Ensuite, nous utiliserons à nouveau lil-gui pour fournir une interface simple.

import {GUI} from 'three/addons/libs/lil-gui.module.min.js';

Comme nous l'avons fait dans les exemples précédents avec lil-gui, nous utiliserons une classe simple pour donner à lil-gui un objet qu'il peut manipuler en degrés mais qui définira une propriété en radians.

class DegRadHelper {
  constructor(obj, prop) {
    this.obj = obj;
    this.prop = prop;
  }
  get value() {
    return THREE.MathUtils.radToDeg(this.obj[this.prop]);
  }
  set value(v) {
    this.obj[this.prop] = THREE.MathUtils.degToRad(v);
  }
}

Nous avons également besoin d'une classe qui convertira une chaîne de caractères comme "123" en un nombre comme 123, car three.js nécessite des nombres pour les paramètres d'énumération comme wrapS et wrapT, mais lil-gui n'utilise que des chaînes de caractères pour les énumérations.

class StringToNumberHelper {
  constructor(obj, prop) {
    this.obj = obj;
    this.prop = prop;
  }
  get value() {
    return this.obj[this.prop];
  }
  set value(v) {
    this.obj[this.prop] = parseFloat(v);
  }
}

En utilisant ces classes, nous pouvons configurer une interface graphique simple pour les paramètres ci-dessus

const wrapModes = {
  'ClampToEdgeWrapping': THREE.ClampToEdgeWrapping,
  'RepeatWrapping': THREE.RepeatWrapping,
  'MirroredRepeatWrapping': THREE.MirroredRepeatWrapping,
};

function updateTexture() {
  texture.needsUpdate = true;
}

const gui = new GUI();
gui.add(new StringToNumberHelper(texture, 'wrapS'), 'value', wrapModes)
  .name('texture.wrapS')
  .onChange(updateTexture);
gui.add(new StringToNumberHelper(texture, 'wrapT'), 'value', wrapModes)
  .name('texture.wrapT')
  .onChange(updateTexture);
gui.add(texture.repeat, 'x', 0, 5, .01).name('texture.repeat.x');
gui.add(texture.repeat, 'y', 0, 5, .01).name('texture.repeat.y');
gui.add(texture.offset, 'x', -2, 2, .01).name('texture.offset.x');
gui.add(texture.offset, 'y', -2, 2, .01).name('texture.offset.y');
gui.add(texture.center, 'x', -.5, 1.5, .01).name('texture.center.x');
gui.add(texture.center, 'y', -.5, 1.5, .01).name('texture.center.y');
gui.add(new DegRadHelper(texture, 'rotation'), 'value', -360, 360)
  .name('texture.rotation');

La dernière chose à noter à propos de l'exemple est que si vous changez wrapS ou wrapT sur la texture, vous devez également définir texture.needsUpdate afin que three.js sache qu'il doit appliquer ces paramètres. Les autres paramètres sont appliqués automatiquement.

Ce n'est qu'une étape dans le sujet des textures. À un moment donné, nous aborderons les coordonnées de texture ainsi que 9 autres types de textures qui peuvent être appliqués aux matériaux.

Pour l'instant, passons aux lumières.