# 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

cliquez ici pour ouvrir dans une fenêtre séparée

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.

cliquez ici pour ouvrir dans une fenêtre séparée

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

cliquez ici pour ouvrir dans une fenêtre séparée

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

cliquez ici pour ouvrir dans une fenêtre séparée

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.