# Оптимизация большого количества анимированных объектов

Эта статья является продолжением статьи об оптимизации множества объектов . Если вы еще не прочитали это, пожалуйста, прочитайте его, прежде чем продолжить.

В предыдущей статье мы объединили около 19000 кубов в одну геометрию. Это имело преимущество, заключающееся в том, что оно оптимизировало наш рисунок из 19000 кубов, но имело тот недостаток, что затрудняло перемещение любого отдельного куба.

В зависимости от того, чего мы пытаемся достичь, существуют разные решения. В этом случае давайте наметим несколько наборов данных и анимируем между наборами.

Первое, что нам нужно сделать, это получить несколько наборов данных. В идеале мы бы, вероятно, предварительно обрабатывали данные в автономном режиме, но в этом случае давайте загрузим 2 набора данных и сгенерируем еще 2

Вот наш старый код загрузки

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

Давайте изменим это на что-то вроде этого

    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();

Приведенный выше код загрузит все файлы в `fileInfos`, и после этого каждый объект в `fileInfos` будет иметь свойство `file` с загруженным файлом. `name` и `hueRange` мы будем использовать позже. `name` будет для поля пользовательского интерфейса. `hueRange` будет использоваться для выбора диапазона оттенков для отображения.

Два файла выше, по-видимому, представляют собой количество мужчин на область и число женщин на область по состоянию на 2010 год. Обратите внимание, я не знаю, верны ли эти данные, но на самом деле это не важно. Важная часть показывает разные наборы данных.

Давайте сгенерируем еще 2 набора данных. Одним из них являются места, где число мужчин превышает число женщин, и наоборот, места, где число женщин превышает число мужчин.

Первым делом давайте напишем функцию, которая с помощью двухмерного массива массивов, как мы делали раньше, отобразит ее, чтобы сгенерировать новый двумерный массив массивов.

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

Как и обычная функция `Array.map`, функция `mapValues` вызывает функцию fn для каждого значения в массиве массивов. Он передает ему значение, а также индексы строки и столбца.

Теперь давайте создадим некоторый код для генерации нового файла, который сравнивает 2 файла.

    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};
    }

Приведенный выше код использует `mapValues` для генерации нового набора данных, который представляет собой сравнение на основе переданной функции `CompareFn`. Он также отслеживает минимальные и максимальные результаты сравнения. Наконец, он создает новый файл со всеми теми же свойствами, что и `baseFile`, за исключением новых `min`, `max` и `data`.

Тогда давайте использовать это, чтобы сделать 2 новых набора данных

    {
      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);
        }),
      });
    }

Теперь давайте сгенерируем пользовательский интерфейс для выбора между этими наборами данных. Для начала нам нужен HTML-интерфейс

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

и немного CSS, чтобы он появился в верхней левой области

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

Затем мы можем просмотреть каждый файл и сгенерировать набор объединенных блоков для набора данных и элемент, который при наведении курсора отобразит этот набор и скроет все остальные.

    // show the selected data, hide the rest
    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);
      });
    });
    // show the first set of data
    showFileInfo(fileInfos, fileInfos[0]);

Еще одно изменение, которое нам нужно из предыдущего примера, заключается в том, что мы должны заставить `addBoxes` принимать `hueRange`

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

      ...

        // compute a color
    -    const hue = THREE.MathUtils.lerp(0.7, 0.3, amount);
    +    const hue = THREE.MathUtils.lerp(...hueRange, amount);

      ...

и с этим мы должны быть в состоянии показать 4 набора данных. Наведите указатель мыши на ярлыки или коснитесь их, чтобы переключать наборы

нажмите здесь, чтобы открыть в отдельном окне

Обратите внимание, что есть несколько странных точек данных, которые действительно выделяются. Интересно, что с ними? ??! В любом случае, как мы анимируем между этими 4 наборами данных.

Много идей.

- Просто исчезните между ними, используя `Material.opacity`

  Проблема с этим решением заключается в том, что кубы полностью перекрываются, что означает, что возникнут проблемы z-борьбы. Возможно, мы могли бы исправить это, изменив функцию глубины и используя смешивание. Вероятно, мы должны изучить это.

- Увеличьте набор, который мы хотим видеть, и уменьшите другие наборы.

  Поскольку все коробки имеют свое происхождение в центре планеты, если мы масштабируем их ниже 1,0, они погрузятся в планету. Сначала это звучит как хорошая идея, но проблема в том, что все поля низкой высоты исчезнут почти сразу и не будут заменены, пока новый набор данных не масштабируется до 1,0. Это делает переход не очень приятным. Мы могли бы исправить это с помощью необычного шейдера.

- Используйте Morphtargets

  Morphtargets - это способ, которым мы предоставляем несколько значений для каждой вершины в геометрии и морф или lerp (линейная интерполяция) между ними. Morphtargets чаще всего используются для лицевой анимации 3D персонажей, но это не единственное их использование.

Давайте попробуем morphtargets.

Мы по-прежнему создадим геометрию для каждого набора данных, но затем мы извлечем атрибут позиции из каждого из них и будем использовать их как морфтинги.

Сначала давайте изменим `addBoxes`, чтобы просто создать и вернуть объединенную геометрию.

    -function addBoxes(file, hueRange) {
    +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);
    }

Здесь есть еще одна вещь, которую нам нужно сделать. Morphtargets требуются, чтобы у всех было точно одинаковое количество вершин. Вершина # 123 в одной цели должна иметь соответствующую вершину # 123 во всех других целях. Но, поскольку сейчас разные наборы данных могут иметь некоторые точки данных без данных, поэтому для этой точки не будет сгенерировано ни одного блока, что означало бы отсутствие соответствующих вершин для другого набора. Итак, нам нужно проверить все наборы данных и либо всегда генерировать что-либо, если в каком-либо наборе есть данные, либо ничего не генерировать, если в каком-либо наборе отсутствуют данные. Давайте сделаем последнее.

    +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;

      ...

Теперь мы изменим код, который вызывал `addBoxes`, для использования `makeBoxes` и установки morphtargets.

    +// make geometry for each data set
    +const geometries = fileInfos.map((info) => {
    +  return makeBoxes(info.file, info.hueRange, fileInfos);
    +});
    +
    +// use the first geometry as the base
    +// and add all the geometries as 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);
    });
    // show the first set of data
    showFileInfo(fileInfos, fileInfos[0]);

Выше мы создаем геометрию для каждого набора данных, используем первый в качестве базы, затем получаем атрибут позиции из каждой геометрии и добавляем его в качестве морфтинга к базовой геометрии для позиции.

Теперь нам нужно изменить способ отображения и скрытия различных наборов данных. Вместо того, чтобы показывать или скрывать меш, нам нужно изменить влияние морфтинга. Для набора данных, который мы хотим видеть, нам нужно иметь влияние 1, а для всех тех, которые мы не хотим видеть, нам нужно иметь влияние 0.

Мы могли бы просто установить их в 0 или 1 напрямую, но если бы мы это сделали, мы бы не увидели никакой анимации, она просто щелкала бы, что не отличалось бы от того, что у нас уже есть. Мы также могли бы написать некоторый пользовательский анимационный код, который был бы легок, но поскольку оригинальный глобус webgl использует библиотеку анимации давайте используем тот же самый здесь.

Нам нужно включить библиотеку

    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';

А затем создайте `Tween` чтобы оживить влияние.

    // show the selected data, hide the rest
    function showFileInfo(fileInfos, fileInfo) {
      fileInfos.forEach((info) => {
        const visible = fileInfo === info;
    -    info.root.visible = visible;
        info.elem.className = visible ? 'selected' : '';
    +    const targets = {};
    +    fileInfos.forEach((info, i) => {
    +      targets[i] = info === fileInfo ? 1 : 0;
    +    });
    +    const durationInMs = 1000;
    +    new TWEEN.Tween(mesh.morphTargetInfluences)
    +      .to(targets, durationInMs)
    +      .start();
      });
      requestRenderIfNotRequested();
    }

Мы также предполагаем вызывать TWEEN.update каждый кадр в нашем цикле рендеринга, но это указывает на проблему. "tween.js" предназначен для непрерывного рендеринга, но мы делаем рендеринг по требованию . Мы могли бы переключиться на непрерывный рендеринг, но иногда приятно рендерить только по требованию, так как он перестает использовать силу пользователя, когда ничего не происходит, поэтому давайте посмотрим, сможем ли мы сделать его анимированным по запросу.

Мы сделаем `TweenManager`, чтобы помочь. Мы будем использовать его для создания `Tweens` и отслеживания их. Он будет иметь метод `update`, который будет возвращать true, если нам нужно будет вызвать его снова, и false, если все анимации завершены.

    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;
      }
    }

Чтобы использовать его, мы создадим один

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

      ...

Мы будем использовать его для создания наших `Tween`s.

    // show the selected data, hide the rest
    function showFileInfo(fileInfos, fileInfo) {
      fileInfos.forEach((info) => {
        const visible = fileInfo === info;
        info.elem.className = visible ? 'selected' : '';
        const targets = {};
        fileInfos.forEach((info, i) => {
          targets[i] = info === fileInfo ? 1 : 0;
        });
        const durationInMs = 1000;
    -    new TWEEN.Tween(mesh.morphTargetInfluences)
    +    tweenManager.createTween(mesh.morphTargetInfluences)
          .to(targets, durationInMs)
          .start();
      });
      requestRenderIfNotRequested();
    }

Затем мы обновим наш цикл рендеринга, чтобы обновить анимацию и продолжать рендеринг, если анимация все еще выполняется.

    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();

И с этим мы должны анимировать между наборами данных.

нажмите здесь, чтобы открыть в отдельном окне

Я надеюсь, что пройти через это было полезно. Использование morphtargets либо через сервисы, которые предоставляет three.js, либо путем написания пользовательских шейдеров - это распространенная техника для перемещения большого количества объектов. В качестве примера мы могли бы дать каждому кубу случайное место в другой цели и превратить его в свои первые позиции на земном шаре. Это может быть крутой способ представить миру.

Далее вас может заинтересовать добавление ярлыков к глобусу, который описан в разделе. «Выравнивание элементов HTML в 3D».

Примечание: мы могли бы попытаться просто изобразить процент мужчин или женщин или общую разницу, но основываясь на том, как мы отображаем информацию, кубы, которые растут с поверхности земли, мы бы предпочли, чтобы большинство кубов были низкими. Если бы мы использовали одно из этих других сравнений, то большинство кубов имели бы примерно половину их максимальной высоты, что не давало бы хорошей визуализации. Не стесняйтесь изменить количество GreaterThan с Math.max (a - b, 0) на что-то вроде (a - b) «сырой разницы» или a / (a ​​+ b) «процентов», и вы поймете, что я имею в виду.