Les textures dans

Cet article fait partie d'une série consacrée à Three.js. Le premier article s'intitule Principes de base. L'article précédent concerné la configuration nécessaire pour cet article. Si vous ne l'avez pas encore lu, vous voudriez peut-être commencer par là.

Les textures sont un gros sujet dans Three.js et je ne suis pas sûr de pouvoir les expliquer à 100% mais je vais essayer. Il y a de nombreuses choses à voir et beaucoup d'entre elles sont interdépendantes, il est donc difficile de les expliquer tous en même temps. Voici une table des matières rapide pour cet article.

Hello Texture

Les textures sont generallement 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.

Modifions l'un de nos premiers échantillons. Tout ce que nous avons à faire, c'est de créer un TextureLoader. Appelons-le avec sa méthode load et l'URL d'une image définissons la propriété map du matériau sur le résultat au lieu de définir sa color.

+const loader = new THREE.TextureLoader();

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

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

6 textures, une pour chaque face d'un cube

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

Fabriquons juste 6 materiaux et passons-les sous forme de tableau lors de la création de la Mesh

const loader = new THREE.TextureLoader();

-const material = new THREE.MeshBasicMaterial({
-  map: loader.load('resources/images/wall.jpg'),
-});
+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')}),
+];
-const cube = new THREE.Mesh(geometry, material);
+const cube = new THREE.Mesh(geometry, materials);

Ça marche !

Il convient de noter, cependant, que tous les types de géométries ne peuvent supporter la prise en charge de plusieurs matériaux. BoxGeometry ne peut utiliser que 6 materiaux, un pour chaque face. ConeGeometry, seulement 2, un pour la base et un pour le cône. CylinderGeometry peut recevoir 3 materiaux pour le bas, le haut et le côté. Dans les autres cas, vous devrez créer ou charger une géométrie personnalisée et/ou modifier les coordonnées de texture.

Il est bien plus performant d'utiliser, comme dans bien d'autres moteurs 3D, un atlas de texture si vous voulez utiliser plusieurs images sur une même géométrie. Un atlas de texture est l'endroit où vous placez plusieurs images dans une seule texture, puis utilisez les coordonnées de texture sur les sommets de votre géométrie pour sélectionner les parties d'une texture à utiliser 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'un morceau de géométrie qui spécifient quelle partie de la texture correspond à ce sommet spécifique. Nous les examinerons lorsque nous commencerons à créer une géométrie personnalisée.

Téléchargement de textures

La façon la plus simple

La plupart du code de ce site utilise la méthode la plus simple pour charger les textures. Nous créons un TextureLoader puis appelons sa méthode de chargement. 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, auquel cas elle mettra à jour la texture avec l'image téléchargée.

Le gros avantage, c'est que nous n'avons pas besoin d'attendre que la texture soit chargée pour que notre page s'affiche. C'est probablement correct pour un grand nombre de cas d'utilisation, mais si nous le voulons, nous pouvons demander à Three.js de nous dire quand le téléchargement de la texture est terminé.

En attente du chargement d'une texture

Pour attendre qu'une texture se charge, la méthode load du chargeur de texture prend une 'callback function' qui sera appelée lorsque la texture aura fini de se charger. Pour en revenir à notre meilleur exemple, nous pouvons attendre que la texture se charge avant de créer notre Mesh et de l'ajouter à une scène comme celle-ci

const loader = new THREE.TextureLoader();
loader.load('resources/images/wall.jpg', (texture) => {
  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 de vider le cache de votre navigateur et d'avoir une connexion lente, il est peu probable que vous voyiez la différence, mais soyez assuré qu'il attend le chargement de la texture.

En attente du chargement de plusieurs textures

Pour attendre que toutes les textures soient chargées, vous pouvez utiliser un LoadingManager. Créez-en un et transmettez-le à TextureLoader, puis définissez sa propriété onLoad avec une 'callback function'.

+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);  // ajouter à la liste des cubes
+};

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

Ajoutons d'abord 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 un peu de CSS

#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 la progressbar dans la 'callback function' onProgress. Elle est appelée 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 chargés.

+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);  // ajouter à la liste des cubes
};

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

À moins que vous ne vidiez votre cache et que votre connexion soit lente, vous ne verrez peut-être pas la barre de chargement.

Chargement de textures d'autres origines

Pour utiliser des images d'autres serveurs, ces serveurs doivent envoyer les en-têtes corrects. Si ce n'est pas le cas, vous ne pouvez pas utiliser les images dans Three.js et vous obtiendrez une erreur. Si vous utilisez un serveur distant, assurez-vous qu'il envoie les bons en-têtes. Sinon, vous ne pourrez pas utiliser les images provenant de ce serveur.

Par exemple imgur, flickr, et github envoient des en-têtes vous permettant d'utiliser des images hébergées sur leurs serveurs avec 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, textures prennent width * height * 4 * 1.33 octets de mémoire.

Remarquez que cela ne dit rien sur la compression. Je peux créer une image .jpg et régler sa compression à un niveau très élevé. Par exemple, disons que je souhaite créé une maison. A 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 pèse que 157ko, elle sera donc téléchargée relativement vite mais sa taill est en réalité de 3024 x 3761 pixels. En suivant l'équation ci-dessous

3024 * 3761 * 4 * 1.33 = 60505764.5

Cette image prendra 60 MEGA de MEMOIRE! dans Three.js. Encore quelques textures comme celle-la et vous serez à court de mémoire.

J'en parle car il est important de savoir que l'utilisation de textures a un coût caché. Pour que Three.js utilise la texture, il doit la transmettre au GPU et le GPU en général nécessite que les données de texture soient décompressées.

La morale de l'histoire, c'est d'utiliser des textures de petite taille, pas seulement petite en taille de fichier. Petit en taille de fichier = rapide à télécharger. Petit en dimensions = prend moins de mémoire. Quelle est la bonne taille ? Aussi petite que possible et toujours aussi belle que nécessaire.

JPG ou PNG

C'est à peu près la même chose qu'en HTML, en ce sens que 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 prennent en charge la transparence. Les PNG sont aussi probablement le format approprié pour les données non-image comme les normal maps, et d'autres types de map 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 en WebGL. Voir au ci-dessus.

Filtrage et Mips

Appliquons cette texture 16x16

sur un cube

Rétrécissons-le au max

Hmmm, je suppose que c'est trop difficile à voir. Agrandissons-le un peu

Comment le GPU sait-il quelles couleurs créer pour chaque pixel qu'il dessine pour le petit cube ? Et si le cube était si petit qu'il ne faisait que 1 ou 2 pixels ?

C'est à cela que sert le filtrage.

S'il s'agissait de Photoshop, il ferait la moyenne de presque tous les pixels ensemble pour déterminer la couleur de ces 1 ou 2 pixels. Ce serait une opération très lente. Les GPU résolvent ce problème à l'aide de mipmaps.

Le MIP mapping consiste à envoyer au processeur graphique (GPU) des échantillons de texture de résolutions décroissantes qui seront utilisés à la place de la texture originale, en fonction de la distance du point de vue à l'objet texturé et du niveau de détails nécessaire. Pour l'image précédente seront produites les mêmes images avec des résolutions inférieure jusqu'à obtenir 1 x 1 pixel.

Désormais, lorsque le cube est dessiné si petit qu'il ne fait que 1 ou 2 pixels de large, le GPU peut choisir d'utiliser uniquement le plus petit ou le plus petit niveau de mip pour décider de la couleur du 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, définissez la propriété texture.magFilter sur THREE.NearestFilter ou THREE.LinearFilter. NearestFilter signifie simplement choisir le pixel le plus proche dans la texture d'origine. Avec une texture basse résolution, cela vous donne un look très pixelisé comme 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.

Nearest
Linear

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

  • THREE.NearestFilter

    comme ci-dessus, choisissez le pixel le plus proche dans la texture

  • THREE.LinearFilter

    comme ci-dessus, choisissez 4 pixels dans la texture et mélangez-les

  • THREE.NearestMipmapNearestFilter

    choisissez le mip approprié puis choisissez un pixe

  • THREE.NearestMipmapLinearFilter

    choisissez 2 mips, choisissez un pixel de chacun, mélangez les 2 pixels

  • THREE.LinearMipmapNearestFilter

    choisissez le mip approprié puis choisissez 4 pixels et mélangez-les

  • THREE.LinearMipmapLinearFilter

    choisissez 2 mips, choisissez 4 pixels de chacun et mélangez les 8 en 1 pixel

Voici un exemple montrant les 6 paramètres

click to
change
texture
nearest
linear
nearest
mipmap
nearest
nearest
mipmap
linear
linear
mipmap
nearest
linear
mipmap
linear

Une chose à noter est que la texture en haut/gauche et la haut/milieu utilisent NearestFilter et LinearFilter et pas les mips. À cause de cela, ils scintillent au loin car le GPU sélectionne les pixels de la texture d'origine. Sur la gauche, un seul pixel est choisi et au milieu, 4 sont choisis et mélangés, mais il ne suffit pas de proposer une bonne couleur représentative. Les 4 autres bandes font mieux avec le bas à droite, LinearMipmapLinearFilter étant le meilleur.

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 clarifie les choses. Vous pouvez voir en haut à gauche et en haut au milieu que le premier mip est utilisé au loin. En haut à droite et en bas au milieu, 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 douce et la plus haute qualité. Vous pourriez vous demander pourquoi ne pas toujours utiliser ce mode. La raison la plus évidente, c'est que parfois vous voulez que les choses soient pixelisées pour un look rétro ou pour une autre raison. La deuxième raison la plus courante, c' est que lire 8 pixels et les mélanger est plus lent que lire 1 pixel et mélanger. Bien qu'il soit peu probable qu'une seule texture fasse la différence entre rapide et lente à mesure que nous progressons dans ces articles, nous finirons par avoir des matériaux qui utilisent 4 ou 5 textures à la fois. 4 textures * 8 pixels par texture se transforment 32 pixels pour chaque pixel rendu. Cela peut être particulièrement important à considérer sur les appareils mobiles.

Répétition, décalage, rotation, emballage 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 de 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 un habillage horizontal et wrapT pour un habillage vertical.

Ils peuvent être définis sur l'un des éléments suivants :

  • THREE.ClampToEdgeWrapping

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

  • THREE.RepeatWrapping

    la texture est répétée

  • THREE.MirroredRepeatWrapping

    la texture est reflétée et répétée

Par exemple pour activer le wrapping dans les deux sens :

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

La répétition est définie avec la propriété [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. En d'autres termes 0 = aucun décalage et 1 = décalage d'une quantité de texture complète.

const xOffset = .5;   // décalage de la moitié de la texture
const yOffset = .25;  // décalage d'un quart
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. La valeur par défaut est 0,0 qui tourne à partir du coin inférieur gauche. Comme le décalage, ces unités ont une taille de texture, donc les régler sur .5, .5 tournerait autour du centre de la texture.

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

Modifions l'échantillon supérieur ci-dessus pour jouer avec ces valeurs.

Tout d'abord, nous allons garder une référence à la texture afin que nous puissions 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, utilisons lil-gui pour fournir une interface simple.

import {GUI} from '/examples/jsm/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 qu'il définira 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 telle que "123" en un nombre tel que 123, car Three.js nécessite des nombres pour les paramètres d'énumération tels que wrapS et wrapT, mais lil-gui n'utilise que des chaînes 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 modifiez wrapS ou wrapT sur la texture, vous devez également définir texture.needsUpdate afin que three.js sache appliquer ces paramètres. Les autres paramètres sont automatiquement appliqués.

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

Pour le moment, passons aux lumières.