Optimiser Beaucoup d'Objets

Cet article fait partie d'une série d'articles sur three.js. Le premier article est les bases de three.js. Si vous ne l'avez pas encore lu et que vous débutez avec three.js, vous pourriez vouloir commencer par là.

Il existe de nombreuses façons d'optimiser les choses pour three.js. Une méthode est souvent appelée fusion de géométrie. Chaque Mesh que vous créez et que three.js représente est 1 ou plusieurs requêtes du système pour dessiner quelque chose. Dessiner 2 choses a plus de surcoût que d'en dessiner 1, même si les résultats sont les mêmes, donc une façon d'optimiser est de fusionner les maillages (meshes).

Voyons un exemple où cela est une bonne solution pour un problème. Recréons le Globe WebGL.

La première chose à faire est d'obtenir des données. Le Globe WebGL a dit que les données qu'ils utilisent proviennent de SEDAC. En consultant le site, j'ai vu qu'il y avait des données démographiques au format grille. J'ai téléchargé les données avec une résolution de 60 minutes. Ensuite, j'ai examiné les données

Cela ressemble à ceci

 ncols         360
 nrows         145
 xllcorner     -180
 yllcorner     -60
 cellsize      0.99999999999994
 NODATA_value  -9999
 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...
 9.241768 8.790958 2.095345 -9999 0.05114867 -9999 -9999 -9999 -9999 -999...
 1.287993 0.4395509 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999...
 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 -9999 ...

Il y a quelques lignes qui sont comme des paires clé/valeur suivies de lignes avec une valeur par point de grille, une ligne pour chaque rangée de points de données.

Pour nous assurer que nous comprenons les données, essayons de les tracer en 2D.

D'abord un peu de code pour charger le fichier texte

async function loadFile(url) {
  const res = await fetch(url);
  return res.text();
}

Le code ci-dessus renvoie une Promise avec le contenu du fichier à l'url ;

Ensuite, nous avons besoin de code pour analyser le fichier

function parseData(text) {
  const data = [];
  const settings = {data};
  let max;
  let min;
  // split into lines
  text.split('\n').forEach((line) => {
    // split the line by whitespace
    const parts = line.trim().split(/\s+/);
    if (parts.length === 2) {
      // only 2 parts, must be a key/value pair
      settings[parts[0]] = parseFloat(parts[1]);
    } else if (parts.length > 2) {
      // more than 2 parts, must be data
      const values = parts.map((v) => {
        const value = parseFloat(v);
        if (value === settings.NODATA_value) {
          return undefined;
        }
        max = Math.max(max === undefined ? value : max, value);
        min = Math.min(min === undefined ? value : min, value);
        return value;
      });
      data.push(values);
    }
  });
  return Object.assign(settings, {min, max});
}

Le code ci-dessus renvoie un objet avec toutes les paires clé/valeur du fichier ainsi qu'une propriété data contenant toutes les données dans un grand tableau et les valeurs min et max trouvées dans les données.

Ensuite, nous avons besoin de code pour dessiner ces données

function drawData(file) {
  const {min, max, data} = file;
  const range = max - min;
  const ctx = document.querySelector('canvas').getContext('2d');
  // make the canvas the same size as the data
  ctx.canvas.width = ncols;
  ctx.canvas.height = nrows;
  // but display it double size so it's not too small
  ctx.canvas.style.width = px(ncols * 2);
  ctx.canvas.style.height = px(nrows * 2);
  // fill the canvas to dark gray
  ctx.fillStyle = '#444';
  ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
  // draw each data point
  data.forEach((row, latNdx) => {
    row.forEach((value, lonNdx) => {
      if (value === undefined) {
        return;
      }
      const amount = (value - min) / range;
      const hue = 1;
      const saturation = 1;
      const lightness = amount;
      ctx.fillStyle = hsl(hue, saturation, lightness);
      ctx.fillRect(lonNdx, latNdx, 1, 1);
    });
  });
}

function px(v) {
  return `${v | 0}px`;
}

function hsl(h, s, l) {
  return `hsl(${h * 360 | 0},${s * 100 | 0}%,${l * 100 | 0}%)`;
}

Et enfin, en liant le tout

loadFile('resources/data/gpw/gpw_v4_basic_demographic_characteristics_rev10_a000_014mt_2010_cntm_1_deg.asc')
  .then(parseData)
  .then(drawData);

Nous donne ce résultat

Donc, cela semble fonctionner.

Essayons-le en 3D. En partant du code de rendu à la demande, nous allons créer une boîte par donnée dans le fichier.

Commençons par créer une simple sphère avec une texture du monde. Voici la texture

Et le code pour le mettre en place.

{
  const loader = new THREE.TextureLoader();
  const texture = loader.load('resources/images/world.jpg', render);
  const geometry = new THREE.SphereGeometry(1, 64, 32);
  const material = new THREE.MeshBasicMaterial({map: texture});
  scene.add(new THREE.Mesh(geometry, material));
}

Notez l'appel à render lorsque la texture a fini de charger. Nous en avons besoin car nous faisons du rendu à la demande au lieu de le faire en continu, nous devons donc rendre la scène une fois que la texture est chargée.

Ensuite, nous devons modifier le code qui dessinait un point par point de donnée ci-dessus pour créer une boîte par point de donnée à la place.

function addBoxes(file) {
  const {min, max, data} = file;
  const range = max - min;

  // make one box geometry
  const boxWidth = 1;
  const boxHeight = 1;
  const boxDepth = 1;
  const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
  // make it so it scales away from the positive Z axis
  geometry.applyMatrix4(new THREE.Matrix4().makeTranslation(0, 0, 0.5));

  // these helpers will make it easy to position the boxes
  // We can rotate the lon helper on its Y axis to the longitude
  const lonHelper = new THREE.Object3D();
  scene.add(lonHelper);
  // We rotate the latHelper on its X axis to the latitude
  const latHelper = new THREE.Object3D();
  lonHelper.add(latHelper);
  // The position helper moves the object to the edge of the sphere
  const positionHelper = new THREE.Object3D();
  positionHelper.position.z = 1;
  latHelper.add(positionHelper);

  const lonFudge = Math.PI * .5;
  const latFudge = Math.PI * -0.135;
  data.forEach((row, latNdx) => {
    row.forEach((value, lonNdx) => {
      if (value === undefined) {
        return;
      }
      const amount = (value - min) / range;
      const material = new THREE.MeshBasicMaterial();
      const hue = THREE.MathUtils.lerp(0.7, 0.3, amount);
      const saturation = 1;
      const lightness = THREE.MathUtils.lerp(0.1, 1.0, amount);
      material.color.setHSL(hue, saturation, lightness);
      const mesh = new THREE.Mesh(geometry, material);
      scene.add(mesh);

      // adjust the helpers to point to the latitude and longitude
      lonHelper.rotation.y = THREE.MathUtils.degToRad(lonNdx + file.xllcorner) + lonFudge;
      latHelper.rotation.x = THREE.MathUtils.degToRad(latNdx + file.yllcorner) + latFudge;

      // use the world matrix of the position helper to
      // position this mesh.
      positionHelper.updateWorldMatrix(true, false);
      mesh.applyMatrix4(positionHelper.matrixWorld);

      mesh.scale.set(0.005, 0.005, THREE.MathUtils.lerp(0.01, 0.5, amount));
    });
  });
}

Le code est principalement direct par rapport à notre code de dessin de test.

Nous créons une boîte et ajustons son centre de manière à ce qu'elle s'éloigne de l'axe Z positif. Si nous ne faisions pas cela, elle s'agrandirait à partir du centre, mais nous voulons qu'elles s'éloignent de l'origine.

par défaut
ajusté

Bien sûr, nous pourrions aussi résoudre cela en faisant de la boîte un enfant d'autres objets THREE.Object3D comme nous l'avons vu dans les graphes de scène, mais plus nous ajoutons de nœuds à un graphe de scène, plus cela devient lent.

Nous avons également mis en place cette petite hiérarchie de nœuds : lonHelper, latHelper, et positionHelper. Nous utilisons ces objets pour calculer une position autour de la sphère où placer la boîte.

Ci-dessus, la barre verte représente lonHelper et est utilisée pour pivoter vers la longitude sur l'équateur. La barre bleue représente latHelper qui est utilisée pour pivoter vers une latitude au-dessus ou en dessous de l'équateur. La sphère rouge représente le décalage que fournit positionHelper.

Nous pourrions faire tous les calculs manuellement pour déterminer les positions sur le globe, mais faire les choses de cette manière laisse la majeure partie des calculs à la librairie elle-même, de sorte que nous n'avons pas à nous en occuper.

Pour chaque point de donnée, nous créons un MeshBasicMaterial et un Mesh et puis nous demandons la matrice monde du positionHelper et l'appliquons au nouveau Mesh. Enfin, nous mettons à l'échelle le maillage à sa nouvelle position.

Comme ci-dessus, nous aurions pu également créer un latHelper, un lonHelper et un positionHelper pour chaque nouvelle boîte, mais cela aurait été encore plus lent.

Il y a jusqu'à 360x145 boîtes que nous allons créer. Cela représente jusqu'à 52000 boîtes. Comme certains points de données sont marqués comme "NO_DATA", le nombre réel de boîtes que nous allons créer est d'environ 19000. Si nous ajoutions 3 objets d'aide supplémentaires par boîte, cela représenterait près de 80000 nœuds dans le graphe de scène pour lesquels THREE.js devrait calculer les positions. En utilisant plutôt un seul ensemble d'aides pour positionner simplement les maillages, nous économisons environ 60000 opérations.

Une note sur lonFudge et latFudge. lonFudge est π/2, ce qui correspond à un quart de tour. Cela a du sens. Cela signifie simplement que la texture ou les coordonnées de texture commencent à un décalage différent autour du globe. latFudge, d'un autre côté, je n'ai aucune idée pourquoi il doit être π * -0.135, c'est juste un montant qui a permis aux boîtes de s'aligner avec la texture.

La dernière chose à faire est d'appeler notre chargeur

loadFile('resources/data/gpw/gpw_v4_basic_demographic_characteristics_rev10_a000_014mt_2010_cntm_1_deg.asc')
  .then(parseData)
-  .then(drawData)
+  .then(addBoxes)
+  .then(render);

Une fois que les données ont fini de charger et d'être analysées, nous devons rendre la scène au moins une fois puisque nous faisons du rendu à la demande.

Si vous essayez de faire pivoter l'exemple ci-dessus en faisant glisser la souris sur l'échantillon, vous remarquerez probablement que c'est lent.

Nous pouvons vérifier la fréquence d'images en ouvrant les outils de développement et en activant l'indicateur de fréquence d'images du navigateur.

Sur ma machine, je vois une fréquence d'images inférieure à 20 ips.

Cela ne me semble pas très fluide et je suspecte que beaucoup de gens ont des machines plus lentes, ce qui rendrait la situation encore pire. Nous ferions mieux d'étudier l'optimisation.

Pour ce problème particulier, nous pouvons fusionner toutes les boîtes en une seule géométrie. Nous dessinons actuellement environ 19000 boîtes. En les fusionnant en une seule géométrie, nous supprimerions 18999 opérations.

Voici le nouveau code pour fusionner les boîtes en une seule géométrie.

function addBoxes(file) {
  const {min, max, data} = file;
  const range = max - min;

-  // make one box geometry
-  const boxWidth = 1;
-  const boxHeight = 1;
-  const boxDepth = 1;
-  const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);
-  // make it so it scales away from the positive Z axis
-  geometry.applyMatrix4(new THREE.Matrix4().makeTranslation(0, 0, 0.5));

  // these helpers will make it easy to position the boxes
  // We can rotate the lon helper on its Y axis to the longitude
  const lonHelper = new THREE.Object3D();
  scene.add(lonHelper);
  // We rotate the latHelper on its X axis to the latitude
  const latHelper = new THREE.Object3D();
  lonHelper.add(latHelper);
  // The position helper moves the object to the edge of the sphere
  const positionHelper = new THREE.Object3D();
  positionHelper.position.z = 1;
  latHelper.add(positionHelper);
+  // Utilisé pour déplacer le centre de la boîte afin qu'elle s'agrandisse à partir de l'axe Z positif
+  const originHelper = new THREE.Object3D();
+  originHelper.position.z = 0.5;
+  positionHelper.add(originHelper);

  const lonFudge = Math.PI * .5;
  const latFudge = Math.PI * -0.135;
+  const geometries = [];
  data.forEach((row, latNdx) => {
    row.forEach((value, lonNdx) => {
      if (value === undefined) {
        return;
      }
      const amount = (value - min) / range;

-      const material = new THREE.MeshBasicMaterial();
-      const hue = THREE.MathUtils.lerp(0.7, 0.3, amount);
-      const saturation = 1;
-      const lightness = THREE.MathUtils.lerp(0.1, 1.0, amount);
-      material.color.setHSL(hue, saturation, lightness);
-      const mesh = new THREE.Mesh(geometry, material);
-      scene.add(mesh);

+      const boxWidth = 1;
+      const boxHeight = 1;
+      const boxDepth = 1;
+      const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);

      // adjust the helpers to point to the latitude and longitude
      lonHelper.rotation.y = THREE.MathUtils.degToRad(lonNdx + file.xllcorner) + lonFudge;
      latHelper.rotation.x = THREE.MathUtils.degToRad(latNdx + file.yllcorner) + latFudge;

-      // use the world matrix of the position helper to
-      // position this mesh.
-      positionHelper.updateWorldMatrix(true, false);
-      mesh.applyMatrix4(positionHelper.matrixWorld);
-
-      mesh.scale.set(0.005, 0.005, THREE.MathUtils.lerp(0.01, 0.5, amount));

+      // use the world matrix of the origin helper to
+      // position this geometry
+      positionHelper.scale.set(0.005, 0.005, THREE.MathUtils.lerp(0.01, 0.5, amount));
+      originHelper.updateWorldMatrix(true, false);
+      geometry.applyMatrix4(originHelper.matrixWorld);
+
+      geometries.push(geometry);
    });
  });

+  const mergedGeometry = BufferGeometryUtils.mergeGeometries(
+      geometries, false);
+  const material = new THREE.MeshBasicMaterial({color:'red'});
+  const mesh = new THREE.Mesh(mergedGeometry, material);
+  scene.add(mesh);

}

Ci-dessus, nous avons supprimé le code qui modifiait le point central de la géométrie de la boîte et le faisons à la place en ajoutant un originHelper. Auparavant, nous utilisions la même géométrie 19000 fois. Cette fois, nous créons une nouvelle géométrie pour chaque boîte et comme nous allons utiliser applyMatrix pour déplacer les sommets de chaque géométrie de boîte, autant le faire une fois au lieu de deux.

À la fin, nous passons un tableau de toutes les géométries à BufferGeometryUtils.mergeGeometries, ce qui les combinera toutes en un seul maillage.

Nous devons également inclure le BufferGeometryUtils

import * as BufferGeometryUtils from 'three/addons/utils/BufferGeometryUtils.js';

Et maintenant, du moins sur ma machine, j'obtiens 60 images par seconde

Cela a donc fonctionné, mais comme il s'agit d'un seul maillage, nous n'obtenons qu'un seul matériau, ce qui signifie que nous n'avons qu'une seule couleur, alors qu'avant, nous avions une couleur différente sur chaque boîte. Nous pouvons y remédier en utilisant les couleurs de sommet.

Les couleurs de sommet ajoutent une couleur par sommet. En réglant toutes les couleurs de chaque sommet de chaque boîte sur des couleurs spécifiques, chaque boîte aura une couleur différente.

+const color = new THREE.Color();

const lonFudge = Math.PI * .5;
const latFudge = Math.PI * -0.135;
const geometries = [];
data.forEach((row, latNdx) => {
  row.forEach((value, lonNdx) => {
    if (value === undefined) {
      return;
    }
    const amount = (value - min) / range;

    const boxWidth = 1;
    const boxHeight = 1;
    const boxDepth = 1;
    const geometry = new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth);

    // adjust the helpers to point to the latitude and longitude
    lonHelper.rotation.y = THREE.MathUtils.degToRad(lonNdx + file.xllcorner) + lonFudge;
    latHelper.rotation.x = THREE.MathUtils.degToRad(latNdx + file.yllcorner) + latFudge;

    // use the world matrix of the origin helper to
    // position this geometry
    positionHelper.scale.set(0.005, 0.005, THREE.MathUtils.lerp(0.01, 0.5, amount));
    originHelper.updateWorldMatrix(true, false);
    geometry.applyMatrix4(originHelper.matrixWorld);

+    // calculer une couleur
+    const hue = THREE.MathUtils.lerp(0.7, 0.3, amount);
+    const saturation = 1;
+    const lightness = THREE.MathUtils.lerp(0.4, 1.0, amount);
+    color.setHSL(hue, saturation, lightness);
+    // obtenir les couleurs sous forme de tableau de valeurs de 0 à 255
+    const rgb = color.toArray().map(v => v * 255);
+
+    // créer un tableau pour stocker les couleurs pour chaque sommet
+    const numVerts = geometry.getAttribute('position').count;
+    const itemSize = 3;  // r, g, b
+    const colors = new Uint8Array(itemSize * numVerts);
+
+    // copier la couleur dans le tableau de couleurs pour chaque sommet
+    colors.forEach((v, ndx) => {
+      colors[ndx] = rgb[ndx % 3];
+    });
+
+    const normalized = true;
+    const colorAttrib = new THREE.BufferAttribute(colors, itemSize, normalized);
+    geometry.setAttribute('color', colorAttrib);

    geometries.push(geometry);
  });
});

Le code ci-dessus recherche le nombre ou les sommets nécessaires en obtenant l'attribut position de la géométrie. Nous créons ensuite un Uint8Array pour y mettre les couleurs. Il ajoute ensuite cela comme un attribut en appelant geometry.setAttribute.

Enfin, nous devons dire à three.js d'utiliser les couleurs de sommet.

const mergedGeometry = BufferGeometryUtils.mergeGeometries(
    geometries, false);
-const material = new THREE.MeshBasicMaterial({color:'red'});
+const material = new THREE.MeshBasicMaterial({
+  vertexColors: true,
+});
const mesh = new THREE.Mesh(mergedGeometry, material);
scene.add(mesh);

Et avec cela, nous retrouvons nos couleurs

La fusion de géométrie est une technique d'optimisation courante. Par exemple, au lieu de 100 arbres, vous pourriez fusionner les arbres en 1 seule géométrie, un tas de roches individuelles en une seule géométrie de roches, une clôture de piquets individuels en un seul maillage de clôture. Un autre exemple dans Minecraft, il ne dessine probablement pas chaque cube individuellement, mais crée plutôt des groupes de cubes fusionnés et supprime également sélectivement les faces qui ne sont jamais visibles.

Le problème avec le fait de tout transformer en un seul maillage est qu'il n'est plus facile de déplacer une partie qui était auparavant séparée. Cependant, selon notre cas d'utilisation, il existe des solutions créatives. Nous en explorerons une dans un autre article.