- Несколько холстов и Несколько сцен

Допустим, вы хотите создать сайт электронной коммерции или сделать страницу с множеством трехмерных диаграмм. На первый взгляд все выглядит просто. Просто сделайте холст для каждой диаграммы. Для каждого холста сделайте Renderer.

Вы быстро обнаружите, что у вас возникли проблемы.

  1. Браузер ограничивает количество контекстов WebGL, которые вы можете иметь.

    Обычно этот предел составляет около 8. Как только вы создадите 9-й контекст, самый старый будет потерян.

  2. Ресурсы WebGL не могут быть разделены между контекстами (no shared memory)

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

Так в чем же решение?

Решением является один холст, который заполняет область просмотра в фоновом режиме и некоторый другой элемент для представления каждого «виртуального» холста. Мы делаем один Renderer и затем одну Scene для каждого виртуального холста. Затем мы проверим положение элементов виртуального холста и, если они будут на экране, мы скажем THREE.js нарисовать их сцену в правильном месте.

С этим решением есть только 1 холст, поэтому мы решаем обе задачи 1 и 2 выше. Мы не будем сталкиваться с ограничением контекста WebGL, потому что мы будем использовать только один контекст. Мы также не будем сталкиваться с проблемами обмена по тем же причинам.

Давайте начнем с простого примера с двумя сценами. Сначала мы сделаем HTML

<canvas id="c">шутки про three js</canvas>
<p>
  <span id="box" class="diagram left"></span>
  Я люблю коробки (boxes). Подарки приходят в коробках.
  Когда я нахожу новую коробку, я всегда рад узнать, что внутри.
</p>
<p>
  <span id="pyramid" class="diagram right"></span>
    Когда я был ребенком, я мечтал отправиться в экспедицию внутри пирамиды (pyramid).
    и найти неоткрытую гробницу, полную мумий и сокровищ.
</p>

Затем мы можем настроить CSS, как-то так

#c {
  position: fixed;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  display: block;
  z-index: -1;
}
.diagram {
  display: inline-block;
  width: 5em;
  height: 3em;
  border: 1px solid black;
}
.left {
  float: left;
  margin-right: .25em;
}
.right {
  float: right;
  margin-left: .25em;
}

Мы устанавливаем холст, чтобы заполнить экран, и мы устанавливаем z-index = -1 , чтобы он появлялся позади других элементов. Нам также нужно указать некоторую ширину и высоту для наших виртуальных элементов холста, поскольку внутри нет ничего, что могло бы придать им какой-либо размер.

Теперь мы сделаем 2 сцены с подсветкой и камерой. К одной сцене мы добавим куб, а к другой ромб.

function makeScene(elem) {
  const scene = new THREE.Scene();

  const fov = 45;
  const aspect = 2;  // по умолчанию для холста
  const near = 0.1;
  const far = 5;
  const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
  camera.position.z = 2;
  camera.position.set(0, 1, 2);
  camera.lookAt(0, 0, 0);

  {
    const color = 0xFFFFFF;
    const intensity = 1;
    const light = new THREE.DirectionalLight(color, intensity);
    light.position.set(-1, 2, 4);
    scene.add(light);
  }

  return {scene, camera, elem};
}

function setupScene1() {
  const sceneInfo = makeScene(document.querySelector('#box'));
  const geometry = new THREE.BoxGeometry(1, 1, 1);
  const material = new THREE.MeshPhongMaterial({color: 'red'});
  const mesh = new THREE.Mesh(geometry, material);
  sceneInfo.scene.add(mesh);
  sceneInfo.mesh = mesh;
  return sceneInfo;
}

function setupScene2() {
  const sceneInfo = makeScene(document.querySelector('#pyramid'));
  const radius = .8;
  const widthSegments = 4;
  const heightSegments = 2;
  const geometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments);
  const material = new THREE.MeshPhongMaterial({
    color: 'blue',
    flatShading: true,
  });
  const mesh = new THREE.Mesh(geometry, material);
  sceneInfo.scene.add(mesh);
  sceneInfo.mesh = mesh;
  return sceneInfo;
}

const sceneInfo1 = setupScene1();
const sceneInfo2 = setupScene2();

И тогда мы сделаем функцию для рендеринга каждой сцены, только если элемент находится на экране. Мы можем указать THREE.js визуализировать только часть холста, включив тест scissor через Renderer.setScissorTest, затем установим плоскость обрезания и область просмотра (Viewport) с помощью Renderer.setViewport и Renderer.setScissor.

function renderSceneInfo(sceneInfo) {
  const {scene, camera, elem} = sceneInfo;

  // получаем относительную позицию окна просмотра этого элемента
  const {left, right, top, bottom, width, height} =
      elem.getBoundingClientRect();

  const isOffscreen =
      bottom < 0 ||
      top > renderer.domElement.clientHeight ||
      right < 0 ||
      left > renderer.domElement.clientWidth;

  if (isOffscreen) {
    return;
  }

  camera.aspect = width / height;
  camera.updateProjectionMatrix();

  const positiveYUpBottom = renderer.domElement.clientHeight - bottom;
  renderer.setScissor(left, positiveYUpBottom, width, height);
  renderer.setViewport(left, positiveYUpBottom, width, height);

  renderer.render(scene, camera);
}

И тогда наша функция рендеринга просто сначала очистит экран, а затем отрисует каждую сцену.

function render(time) {
  time *= 0.001;

  resizeRendererToDisplaySize(renderer);

  renderer.setScissorTest(false);
  renderer.clear(true, true);
  renderer.setScissorTest(true);

  sceneInfo1.mesh.rotation.y = time * .1;
  sceneInfo2.mesh.rotation.y = time * .1;

  renderSceneInfo(sceneInfo1);
  renderSceneInfo(sceneInfo2);

  requestAnimationFrame(render);
}

И вот оно

Вы можете увидеть, где на первом месте <span> находится красный куб, а на втором span - синий ромб.

Синхронизация

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

Если мы дадим каждой области границу

.diagram {
  display: inline-block;
  width: 5em;
  height: 3em;
+  border: 1px solid black;
}

И устанавливаем фон каждой сцены

const scene = new THREE.Scene();
+scene.background = new THREE.Color('red');

И если мы быстро прокрутим вверх и вниз мы увидим проблему. Вот анимация прокрутки, замедленная в 10 раз.

Мы можем использовать другой метод, который имеет другой компромисс. Мы переключим CSS холста с position: fixed на position: absolute.

#c {
-  position: fixed;
+  position: absolute;

Затем мы установим преобразование (transform) холста, чтобы переместить его так, чтобы верх холста находился в верхней части любой части, на которую в данный момент прокручивается страница.

function render(time) {
  ...

  const transform = `translateY(${window.scrollY}px)`;
  renderer.domElement.style.transform = transform;

position: fixed удерживает холст от прокрутки вообще, в то время как остальная часть страницы прокручивалась поверх него. position: absolute позволит холсту прокручиваться с остальной частью страницы, что означает, что все, что мы рисуем, будет прилипать к странице, когда она прокручивается, даже если мы слишком медленны для рендеринга. Когда мы наконец получаем возможность рендеринга, мы перемещаем холст так, чтобы он соответствовал месту прокрутки страницы, и затем мы повторно визуализируем. Это означает, что только на краях окна будут отображаться некоторые не визуализированные биты, но материал в середине страницы должен совпадать, а не скользить. Вот представление результатов нового метода, замедленного в 10 раз.

Делаем это более универсальным

Теперь, когда у нас работает несколько сцен, давайте сделаем это немного более обобщенным.

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

Вот основная функция отрисовки

const sceneElements = [];
function addScene(elem, fn) {
  sceneElements.push({elem, fn});
}

function render(time) {
  time *= 0.001;

  resizeRendererToDisplaySize(renderer);

  renderer.setScissorTest(false);
  renderer.setClearColor(clearColor, 0);
  renderer.clear(true, true);
  renderer.setScissorTest(true);

  const transform = `translateY(${window.scrollY}px)`;
  renderer.domElement.style.transform = transform;

  for (const {elem, fn} of sceneElements) {
    // получаем относительную позицию окна просмотра этого элемента
    const rect = elem.getBoundingClientRect();
    const {left, right, top, bottom, width, height} = rect;

    const isOffscreen =
        bottom < 0 ||
        top > renderer.domElement.clientHeight ||
        right < 0 ||
        left > renderer.domElement.clientWidth;

    if (!isOffscreen) {
      const positiveYUpBottom = renderer.domElement.clientHeight - bottom;
      renderer.setScissor(left, positiveYUpBottom, width, height);
      renderer.setViewport(left, positiveYUpBottom, width, height);

      fn(time, rect);
    }
  }

  requestAnimationFrame(render);
}

Вы можете видеть, что он зацикливается на массиве объектов sceneElements, каждый из которых имеет свойство elem и fn.

Он проверяет, находится ли элемент на экране. Если это так, то он вызывает fn и передает ему текущее время и его прямоугольник.

Теперь установочный код для каждой сцены просто добавляет себя в список сцен

{
  const elem = document.querySelector('#box');
  const {scene, camera} = makeScene();
  const geometry = new THREE.BoxGeometry(1, 1, 1);
  const material = new THREE.MeshPhongMaterial({color: 'red'});
  const mesh = new THREE.Mesh(geometry, material);
  scene.add(mesh);
  addScene(elem, (time, rect) => {
    camera.aspect = rect.width / rect.height;
    camera.updateProjectionMatrix();
    mesh.rotation.y = time * .1;
    renderer.render(scene, camera);
  });
}

{
  const elem = document.querySelector('#pyramid');
  const {scene, camera} = makeScene();
  const radius = .8;
  const widthSegments = 4;
  const heightSegments = 2;
  const geometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments);
  const material = new THREE.MeshPhongMaterial({
    color: 'blue',
    flatShading: true,
  });
  const mesh = new THREE.Mesh(geometry, material);
  scene.add(mesh);
  addScene(elem, (time, rect) => {
    camera.aspect = rect.width / rect.height;
    camera.updateProjectionMatrix();
    mesh.rotation.y = time * .1;
    renderer.render(scene, camera);
  });
}

При этом нам больше не нужно sceneInfo1 и sceneInfo2. Код, который вращал меши, теперь специфичен для каждой сцены.

Использование набора данных в HTML

Еще одна, еще более общая вещь, которую мы можем сделать, это использовать dataset. Это способ добавить свои собственные данные в элемент HTML. Вместо использования id="..." мы будем использовать data-diagram="..." как тут

<canvas id="c"></canvas>
<p>
-  <span id="box" class="diagram left"></span>
+  <span data-diagram="box" class="left"></span>
  Я люблю коробки (boxes). Подарки приходят в коробках.
  Когда я нахожу новую коробку, я всегда рад узнать, что внутри.
</p>
<p>
-  <span id="pyramid" class="diagram left"></span>
+  <span data-diagram="pyramid" class="right"></span>
   Когда я был ребенком, я мечтал отправиться в экспедицию внутри пирамиды (pyramid).
   и найти неоткрытую гробницу, полную мумий и сокровищ.
</p>

Мы можем их изменить селектор CSS, чтобы выбрать для этого

-.diagram
+*[data-diagram] {
  display: inline-block;
  width: 5em;
  height: 3em;
}

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

const sceneInitFunctionsByName = {
  'box': () => {
    const {scene, camera} = makeScene();
    const geometry = new THREE.BoxGeometry(1, 1, 1);
    const material = new THREE.MeshPhongMaterial({color: 'red'});
    const mesh = new THREE.Mesh(geometry, material);
    scene.add(mesh);
    return (time, rect) => {
      mesh.rotation.y = time * .1;
      camera.aspect = rect.width / rect.height;      camera.updateProjectionMatrix();
      renderer.render(scene, camera);
    };
  },
  'pyramid': () => {
    const {scene, camera} = makeScene();
    const radius = .8;
    const widthSegments = 4;
    const heightSegments = 2;
    const geometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments);
    const material = new THREE.MeshPhongMaterial({
      color: 'blue',
      flatShading: true,
    });
    const mesh = new THREE.Mesh(geometry, material);
    scene.add(mesh);
    return (time, rect) => {
      mesh.rotation.y = time * .1;
      camera.aspect = rect.width / rect.height;
      camera.updateProjectionMatrix();
      renderer.render(scene, camera);
    };
  },
};

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

document.querySelectorAll('[data-diagram]').forEach((elem) => {
  const sceneName = elem.dataset.diagram;
  const sceneInitFunction = sceneInitFunctionsByName[sceneName];
  const sceneRenderFunction = sceneInitFunction(elem);
  addScene(elem, sceneRenderFunction());
});

Никаких изменений в визуальных элементах, но код легче переиспользовать.

Добавление элементов управления к каждому элементу

Например, добавление TrackballControls в интерактивном режиме так же просто. Сначала мы добавляем скрипт для контролов.

import {TrackballControls} from 'three/addons/controls/TrackballControls.js';

И затем мы можем добавить TrackballControls к каждой сцене, передавая элемент, связанный с этой сценой.

-function makeScene() {
+function makeScene(elem) {
  const scene = new THREE.Scene();

  const fov = 45;
  const aspect = 2;  // по умолчанию для canvas
  const near = 0.1;
  const far = 5;
  const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
  camera.position.set(0, 1, 2);
  camera.lookAt(0, 0, 0);
+  scene.add(camera);

+  const controls = new TrackballControls(camera, elem);
+  controls.noZoom = true;
+  controls.noPan = true;

  {
    const color = 0xFFFFFF;
    const intensity = 1;
    const light = new THREE.DirectionalLight(color, intensity);
    light.position.set(-1, 2, 4);
-    scene.add(light);
+    camera.add(light);
  }

-  return {scene, camera};
+ return {scene, camera, controls};
}

Вы заметите, что мы добавили камеру на сцену и свет на камеру. Это делает свет относительно камеры. Поскольку TrackballControls двигает камеру, это, вероятно, то, что мы хотим. Он сохраняет свет, сияющий на стороне объекта, на который мы смотрим.

Нам нужно обновить эти элементы управления в наших функциях отрисовки

const sceneInitFunctionsByName = {
- 'box': () => {
-    const {scene, camera} = makeScene();
+ 'box': (elem) => {
+    const {scene, camera, controls} = makeScene(elem);
    const geometry = new THREE.BoxGeometry(1, 1, 1);
    const material = new THREE.MeshPhongMaterial({color: 'red'});
    const mesh = new THREE.Mesh(geometry, material);
    scene.add(mesh);
    return (time, rect) => {
      mesh.rotation.y = time * .1;
      camera.aspect = rect.width / rect.height;
      camera.updateProjectionMatrix();
+      controls.handleResize();
+      controls.update();
      renderer.render(scene, camera);
    };
  },
-  'pyramid': () => {
-    const {scene, camera} = makeScene();
+  'pyramid': (elem) => {
+    const {scene, camera, controls} = makeScene(elem);
    const radius = .8;
    const widthSegments = 4;
    const heightSegments = 2;
    const geometry = new THREE.SphereGeometry(radius, widthSegments, heightSegments);
    const material = new THREE.MeshPhongMaterial({
      color: 'blue',
      flatShading: true,
    });
    const mesh = new THREE.Mesh(geometry, material);
    scene.add(mesh);
    return (time, rect) => {
      mesh.rotation.y = time * .1;
      camera.aspect = rect.width / rect.height;
      camera.updateProjectionMatrix();
+      controls.handleResize();
+      controls.update();
      renderer.render(scene, camera);
    };
  },
};

Теперь, если вы перетащите объекты, они будут вращаться.

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