Optimiser de nombreux objets animés

Cet article est une continuation de un article sur l'optimisation de nombreux objets . Si vous ne l'avez pas encore lu, veuillez le lire avant de poursuivre.

Dans l'article précédent, nous avons fusionné environ 19000 cubes en une seule géométrie. Cela a eu l'avantage d'optimiser notre dessin de 19000 cubes, mais cela a eu l'inconvénient de rendre plus difficile le déplacement d'un cube individuel.

Selon ce que nous essayons d'accomplir, il existe différentes solutions. Dans ce cas, affichons plusieurs ensembles de données et animons la transition entre les ensembles.

La première chose à faire est d'obtenir plusieurs ensembles de données. Idéalement, nous pré-traiterions probablement les données hors ligne, mais dans ce cas, chargeons 2 ensembles de données et générons-en 2 autres.

Voici notre ancien code de chargement

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

Changeons-le pour quelque chose comme ceci

async function loadData(info) {
  const text = await loadFile(info.url);
  info.file = parseData(text);
}

async function loadAll() {
  const fileInfos = [
    {name: 'men',   hueRange: [0.7, 0.3], url: 'resources/data/gpw/gpw_v4_basic_demographic_characteristics_rev10_a000_014mt_2010_cntm_1_deg.asc' },
    {name: 'women', hueRange: [0.9, 1.1], url: 'resources/data/gpw/gpw_v4_basic_demographic_characteristics_rev10_a000_014ft_2010_cntm_1_deg.asc' },
  ];

  await Promise.all(fileInfos.map(loadData));

  ...
}
loadAll();

Le code ci-dessus chargera tous les fichiers dans fileInfos et une fois terminé, chaque objet dans fileInfos aura une propriété file contenant le fichier chargé. name et hueRange seront utilisés plus tard. name sera pour un champ d'interface utilisateur. hueRange sera utilisé pour choisir une plage de teintes à appliquer.

Les deux fichiers ci-dessus sont apparemment le nombre d'hommes par zone et le nombre de femmes par zone en 2010. Notez que je n'ai aucune idée si ces données sont correctes, mais ce n'est pas vraiment important. L'important est de montrer différents ensembles de données.

Générons 2 ensembles de données supplémentaires. L'un représentant les lieux où le nombre d'hommes est supérieur au nombre de femmes, et inversement, les lieux où le nombre de femmes est supérieur au nombre d'hommes.

La première chose, écrivons une fonction qui, étant donné un tableau bidimensionnel de tableaux comme nous avions précédemment, va l'appliquer pour générer un nouveau tableau bidimensionnel de tableaux.

function mapValues(data, fn) {
  return data.map((row, rowNdx) => {
    return row.map((value, colNdx) => {
      return fn(value, rowNdx, colNdx);
    });
  });
}

Comme la fonction normale Array.map, la fonction mapValues appelle une fonction fn pour chaque valeur dans le tableau de tableaux. Elle lui passe la valeur ainsi que les indices de ligne et de colonne.

Maintenant, écrivons du code pour générer un nouveau fichier qui est une comparaison entre 2 fichiers.

function makeDiffFile(baseFile, otherFile, compareFn) {
  let min;
  let max;
  const baseData = baseFile.data;
  const otherData = otherFile.data;
  const data = mapValues(baseData, (base, rowNdx, colNdx) => {
    const other = otherData[rowNdx][colNdx];
      if (base === undefined || other === undefined) {
        return undefined;
      }
      const value = compareFn(base, other);
      min = Math.min(min === undefined ? value : min, value);
      max = Math.max(max === undefined ? value : max, value);
      return value;
  });
  // make a copy of baseFile and replace min, max, and data
  // with the new data
  return {...baseFile, min, max, data};
}

Le code ci-dessus utilise mapValues pour générer un nouvel ensemble de données qui est une comparaison basée sur la fonction compareFn passée en paramètre. Il suit également les résultats min et max de la comparaison. Enfin, il crée un nouveau fichier avec toutes les mêmes propriétés que baseFile, sauf avec de nouvelles valeurs pour min, max et data.

Ensuite, utilisons cela pour créer 2 nouveaux ensembles de données.

{
  const menInfo = fileInfos[0];
  const womenInfo = fileInfos[1];
  const menFile = menInfo.file;
  const womenFile = womenInfo.file;

  function amountGreaterThan(a, b) {
    return Math.max(a - b, 0);
  }
  fileInfos.push({
    name: '>50%men',
    hueRange: [0.6, 1.1],
    file: makeDiffFile(menFile, womenFile, (men, women) => {
      return amountGreaterThan(men, women);
    }),
  });
  fileInfos.push({
    name: '>50% women',
    hueRange: [0.0, 0.4],
    file: makeDiffFile(womenFile, menFile, (women, men) => {
      return amountGreaterThan(women, men);
    }),
  });
}

Maintenant, générons une interface utilisateur pour sélectionner parmi ces ensembles de données. Nous avons d'abord besoin d'un peu de HTML pour l'interface utilisateur.

<body>
  <canvas id="c"></canvas>
+  <div id="ui"></div>
</body>

et du CSS pour le faire apparaître en haut à gauche

#ui {
  position: absolute;
  left: 1em;
  top: 1em;
}
#ui>div {
  font-size: 20pt;
  padding: 1em;
  display: inline-block;
}
#ui>div.selected {
  color: red;
}

Ensuite, nous pouvons parcourir chaque fichier et générer un ensemble de boîtes fusionnées par ensemble de données, ainsi qu'un élément qui, au survol, affichera cet ensemble et masquera tous les autres.

// afficher les données sélectionnées, masquer les autres
function showFileInfo(fileInfos, fileInfo) {
  fileInfos.forEach((info) => {
    const visible = fileInfo === info;
    info.root.visible = visible;
    info.elem.className = visible ? 'selected' : '';
  });
  requestRenderIfNotRequested();
}

const uiElem = document.querySelector('#ui');
fileInfos.forEach((info) => {
  const boxes = addBoxes(info.file, info.hueRange);
  info.root = boxes;
  const div = document.createElement('div');
  info.elem = div;
  div.textContent = info.name;
  uiElem.appendChild(div);
  div.addEventListener('mouseover', () => {
    showFileInfo(fileInfos, info);
  });
});
// afficher le premier ensemble de données
showFileInfo(fileInfos, fileInfos[0]);

Une autre modification dont nous avons besoin par rapport à l'exemple précédent est de faire en sorte que addBoxes accepte un hueRange.

-function addBoxes(file) {
+function addBoxes(file, hueRange) {

  ...

    // calculer une couleur
-    const hue = THREE.MathUtils.lerp(0.7, 0.3, amount);
+    const hue = THREE.MathUtils.lerp(...hueRange, amount);

  ...

et avec cela, nous devrions pouvoir afficher 4 ensembles de données. Survolez les étiquettes avec la souris ou touchez-les pour changer d'ensemble.

Notez qu'il y a quelques points de données étranges qui ressortent vraiment. Je me demande ce qui se passe avec ceux-là !??! Dans tous les cas, comment animer entre ces 4 ensembles de données.

Beaucoup d'idées.

  • Faites simplement un fondu entre eux en utilisant Material.opacity

    Le problème avec cette solution est que les cubes se superposent parfaitement, ce qui entraînera des problèmes de z-fighting. Il est possible de résoudre cela en changeant la fonction de profondeur et en utilisant le blending. Nous devrions probablement examiner cette option.

  • Agrandissez l'ensemble que nous voulons voir et réduisez les autres ensembles

    Parce que toutes les boîtes ont leur origine au centre de la planète, si nous les réduisons en dessous de 1.0, elles s'enfonceront dans la planète. Au début, cela semble une bonne idée, mais le problème est que toutes les boîtes de faible hauteur disparaîtront presque immédiatement et ne seront pas remplacées tant que le nouvel ensemble de données n'aura pas atteint 1.0. Cela rend la transition peu agréable. On pourrait peut-être résoudre cela avec un shader personnalisé sophistiqué.

  • Utiliser les Morphtargets

    Les Morphtargets sont un moyen de fournir plusieurs valeurs pour chaque sommet de la géométrie et de les morpher ou de les interpoler linéairement (lerp). Les Morphtargets sont le plus souvent utilisés pour l'animation faciale de personnages 3D, mais ce n'est pas leur seule utilisation.

Essayons les morphtargets.

Nous créerons toujours une géométrie pour chaque ensemble de données, mais nous extrairons ensuite l'attribut position de chacun et les utiliserons comme morphtargets.

Changeons d'abord addBoxes pour qu'elle crée et retourne simplement la géométrie fusionnée.

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

  ...

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

Il y a cependant une autre chose que nous devons faire ici. Les morphtargets doivent tous avoir exactement le même nombre de sommets. Le sommet #123 dans une cible doit avoir un sommet correspondant #123 dans toutes les autres cibles. Mais, tel que c'est actuellement, différents ensembles de données pourraient avoir des points de données sans données, donc aucune boîte ne sera générée pour ce point, ce qui signifierait l'absence de sommets correspondants pour un autre ensemble. Nous devons donc vérifier tous les ensembles de données et soit toujours générer quelque chose s'il y a des données dans n'importe quel ensemble, soit ne rien générer s'il manque des données dans n'importe quel ensemble. Faisons le second cas.

+function dataMissingInAnySet(fileInfos, latNdx, lonNdx) {
+  for (const fileInfo of fileInfos) {
+    if (fileInfo.file.data[latNdx][lonNdx] === undefined) {
+      return true;
+    }
+  }
+  return false;
+}

-function makeBoxes(file, hueRange) {
+function makeBoxes(file, hueRange, fileInfos) {
  const {min, max, data} = file;
  const range = max - min;

  ...

  const geometries = [];
  data.forEach((row, latNdx) => {
    row.forEach((value, lonNdx) => {
+      if (dataMissingInAnySet(fileInfos, latNdx, lonNdx)) {
+        return;
+      }
      const amount = (value - min) / range;

  ...

Maintenant, nous allons changer le code qui appelait addBoxes pour qu'il utilise makeBoxes et configure les morphtargets.

+// créer la géométrie pour chaque ensemble de données
+const geometries = fileInfos.map((info) => {
+  return makeBoxes(info.file, info.hueRange, fileInfos);
+});
+
+// utiliser la première géométrie comme base
+// et ajouter toutes les géométries comme morphtargets
+const baseGeometry = geometries[0];
+baseGeometry.morphAttributes.position = geometries.map((geometry, ndx) => {
+  const attribute = geometry.getAttribute('position');
+  const name = `target${ndx}`;
+  attribute.name = name;
+  return attribute;
+});
+baseGeometry.morphAttributes.color = geometries.map((geometry, ndx) => {
+  const attribute = geometry.getAttribute('color');
+  const name = `target${ndx}`;
+  attribute.name = name;
+  return attribute;
+});
+const material = new THREE.MeshBasicMaterial({
+  vertexColors: true,
+});
+const mesh = new THREE.Mesh(baseGeometry, material);
+scene.add(mesh);

const uiElem = document.querySelector('#ui');
fileInfos.forEach((info) => {
-  const boxes = addBoxes(info.file, info.hueRange);
-  info.root = boxes;
  const div = document.createElement('div');
  info.elem = div;
  div.textContent = info.name;
  uiElem.appendChild(div);
  function show() {
    showFileInfo(fileInfos, info);
  }
  div.addEventListener('mouseover', show);
  div.addEventListener('touchstart', show);
});
// afficher le premier ensemble de données
showFileInfo(fileInfos, fileInfos[0]);

Ci-dessus, nous créons une géométrie pour chaque ensemble de données, utilisons la première comme base, puis obtenons un attribut position de chaque géométrie et l'ajoutons comme morphtarget à la géométrie de base pour position.

Maintenant, nous devons changer la manière dont nous affichons et masquons les différents ensembles de données. Au lieu d'afficher ou de masquer un maillage, nous devons modifier l'influence des morphtargets. Pour l'ensemble de données que nous voulons voir, nous devons avoir une influence de 1, et pour tous ceux que nous ne voulons pas voir, nous devons avoir une influence de 0.

Nous pourrions simplement les régler directement à 0 ou 1, mais si nous faisions cela, nous ne verrions aucune animation, cela se ferait instantanément, ce qui ne serait pas différent de ce que nous avons déjà. Nous pourrions également écrire du code d'animation personnalisé, ce qui serait facile, mais comme le globe webgl original utilise une bibliothèque d'animation, utilisons la même ici.

Nous devons inclure la bibliothèque

import * as THREE from 'three';
import * as BufferGeometryUtils from 'three/addons/utils/BufferGeometryUtils.js';
import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
+import TWEEN from 'three/addons/libs/tween.module.js';

Et ensuite, créer un Tween pour animer les influences.

// afficher les données sélectionnées, masquer les autres
function showFileInfo(fileInfos, fileInfo) {
+  const targets = {};
-  fileInfos.forEach((info) => {
+  fileInfos.forEach((info, i) => {
    const visible = fileInfo === info;
-    info.root.visible = visible;
    info.elem.className = visible ? 'selected' : '';
+    targets[i] = visible ? 1 : 0;
  });
+  const durationInMs = 1000;
+  new TWEEN.Tween(mesh.morphTargetInfluences)
+    .to(targets, durationInMs)
+    .start();
  requestRenderIfNotRequested();
}

Nous sommes également censés appeler TWEEN.update à chaque image dans notre boucle de rendu, mais cela soulève un problème. « tween.js » est conçu pour un rendu continu, mais nous rendons à la demande. Nous pourrions passer au rendu continu, mais il est parfois agréable de ne rendre qu'à la demande car cela permet d'économiser l'énergie de l'utilisateur lorsque rien ne se passe, alors voyons si nous pouvons le faire animer à la demande.

Nous allons créer un TweenManager pour nous aider. Nous l'utiliserons pour créer les Tweens et les suivre. Il aura une méthode update qui retournera true si nous devons l'appeler à nouveau, et false si toutes les animations sont terminées.

class TweenManger {
  constructor() {
    this.numTweensRunning = 0;
  }
  _handleComplete() {
    --this.numTweensRunning;
    console.assert(this.numTweensRunning >= 0);
  }
  createTween(targetObject) {
    const self = this;
    ++this.numTweensRunning;
    let userCompleteFn = () => {};
    // create a new tween and install our own onComplete callback
    const tween = new TWEEN.Tween(targetObject).onComplete(function(...args) {
      self._handleComplete();
      userCompleteFn.call(this, ...args);
    });
    // replace the tween's onComplete function with our own
    // so we can call the user's callback if they supply one.
    tween.onComplete = (fn) => {
      userCompleteFn = fn;
      return tween;
    };
    return tween;
  }
  update() {
    TWEEN.update();
    return this.numTweensRunning > 0;
  }
}

Pour l'utiliser, nous allons en créer un

function main() {
  const canvas = document.querySelector('#c');
  const renderer = new THREE.WebGLRenderer({antialias: true, canvas});
+  const tweenManager = new TweenManger();

  ...

Nous l'utiliserons pour créer nos Tweens.

// afficher les données sélectionnées, masquer les autres
function showFileInfo(fileInfos, fileInfo) {
  const targets = {};
  fileInfos.forEach((info, i) => {
    const visible = fileInfo === info;
    info.elem.className = visible ? 'selected' : '';
    targets[i] = visible ? 1 : 0;
  });
  const durationInMs = 1000;
-  new TWEEN.Tween(mesh.morphTargetInfluences)
+  tweenManager.createTween(mesh.morphTargetInfluences)
    .to(targets, durationInMs)
    .start();
  requestRenderIfNotRequested();
}

Ensuite, nous mettrons à jour notre boucle de rendu pour mettre à jour les tweens et continuer à rendre s'il y a encore des animations en cours.

function render() {
  renderRequested = false;

  if (resizeRendererToDisplaySize(renderer)) {
    const canvas = renderer.domElement;
    camera.aspect = canvas.clientWidth / canvas.clientHeight;
    camera.updateProjectionMatrix();
  }

+  if (tweenManager.update()) {
+    requestRenderIfNotRequested();
+  }

  controls.update();
  renderer.render(scene, camera);
}
render();

Et avec cela, nous devrions pouvoir animer entre les ensembles de données.

J'espère que parcourir ceci a été utile. L'utilisation des morphtargets est une technique courante pour déplacer de nombreux objets. Par exemple, nous pourrions donner à chaque cube un endroit aléatoire dans une autre cible et morpher de là à leurs premières positions sur le globe. Cela pourrait être une façon intéressante de présenter le globe.

Ensuite, vous pourriez être intéressé par l'ajout d'étiquettes à un globe, ce qui est abordé dans Aligner les éléments HTML sur la 3D.

Note : Nous pourrions essayer de simplement représenter le pourcentage d'hommes ou de femmes, ou la différence brute, mais compte tenu de la manière dont nous affichons les informations, des cubes qui poussent depuis la surface de la terre, nous préférerions que la plupart des cubes soient bas. Si nous utilisions l'une de ces autres comparaisons, la plupart des cubes auraient environ la moitié de leur hauteur maximale, ce qui ne donnerait pas une bonne visualisation. N'hésitez pas à changer amountGreaterThan de Math.max(a - b, 0) à quelque chose comme (a - b) « différence brute » ou a / (a + b) « pourcentage » et vous verrez ce que je veux dire.