- Камера

Эта статья является частью серии статей о three.js. Первая была об основах. Если вы её еще не читали, советую вам сделать это.

Давайте поговорим о камерах в three.js. Мы рассмотрели некоторые из них в первой статье , но мы расскажем здесь об этом более подробно.

Самая распространенная камера в Three.js и та, которую мы использовали до этого момента, - PerspectiveCamera. Она дает трехмерный вид, где вещи на расстоянии кажутся меньше, чем вещи рядом.

PerspectiveCamera определяет frustum. Frustum - усеченная пирамида, твердое тело. Под твердым телом я подразумеваю, например, куб, конус, сферу, цилиндр и усеченный конус - все названия различных видов твердых тел.

cube
cone
sphere
cylinder
frustum

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

A PerspectiveCameraопределяет свой frustum на основе 4 свойств. near определяет, где начинается фронт усечения. far определяет, где он заканчивается. fovполе обзора определяет высоту передней и задней частей усеченного конуса, вычисляя правильную высоту, чтобы получить указанное поле обзора в near единицах измерения от камеры. aspect определяет, насколько широким передние и задняя часть усеченного есть. Ширина усеченного конуса - это просто высота, умноженная на aspect.

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

Для этого мы сделаем MinMaxGUIHelper для параметров near и far, так чтобы far всегда был больше, чем near. У него будут свойства min и max, которые lil-gui будет настраивать. После настройки они установят 2 свойства, которые мы указываем.

class MinMaxGUIHelper {
  constructor(obj, minProp, maxProp, minDif) {
    this.obj = obj;
    this.minProp = minProp;
    this.maxProp = maxProp;
    this.minDif = minDif;
  }
  get min() {
    return this.obj[this.minProp];
  }
  set min(v) {
    this.obj[this.minProp] = v;
    this.obj[this.maxProp] = Math.max(this.obj[this.maxProp], v + this.minDif);
  }
  get max() {
    return this.obj[this.maxProp];
  }
  set max(v) {
    this.obj[this.maxProp] = v;
    this.min = this.min;  // это вызовет setter min 
  }
}

Теперь мы можем настроить наш графический интерфейс следующим образом

function updateCamera() {
  camera.updateProjectionMatrix();
}

const gui = new GUI();
gui.add(camera, 'fov', 1, 180).onChange(updateCamera);
const minMaxGUIHelper = new MinMaxGUIHelper(camera, 'near', 'far', 0.1);
gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near').onChange(updateCamera);
gui.add(minMaxGUIHelper, 'max', 0.1, 50, 0.1).name('far').onChange(updateCamera);

Каждый раз, когда меняются настройки камеры, нам нужно вызывать функцию камеры updateProjectionMatrix поэтому мы сделали функцию updateCamera передав ее в lil-gui, чтобы вызывать ее, когда что-то меняется.

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

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

Для этого мы можем использовать функцию ножниц (scissor) Three.js. Давайте изменим это, чтобы нарисовать 2 сцены с 2 камерами рядом, используя функцию scissor

Для начала давайте используем HTML и CSS, чтобы определить 2 элемента рядом друг с другом. Это также поможет нам с событиями, так что обе камеры могут иметь свои собственные OrbitControls.

<body>
  <canvas id="c"></canvas>
+  <div class="split">
+     <div id="view1" tabindex="1"></div>
+     <div id="view2" tabindex="2"></div>
+  </div>
</body>

Для начала давайте используем HTML и CSS, чтобы расположить 2 элемента рядом друг с другом. Это также поможет нам с событиями, так что обе камеры могут иметь свои собственные

.split {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  display: flex;
}
.split>div {
  width: 100%;
  height: 100%;
}

Затем в нашем коде мы добавим CameraHelper. CameraHelper рисует frustum для Camera

const cameraHelper = new THREE.CameraHelper(camera);

...

scene.add(cameraHelper);

Теперь давайте посмотрим на 2 элемента view.

const view1Elem = document.querySelector('#view1'); 
const view2Elem = document.querySelector('#view2');

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

-const controls = new OrbitControls(camera, canvas);
+const controls = new OrbitControls(camera, view1Elem);

Создадим вторую PerspectiveCamera и вторую OrbitControls. Вторая OrbitControls привязана ко второй камере и получает ввод от второго элемента view.

const camera2 = new THREE.PerspectiveCamera(
  60,  // fov
  2,   // aspect
  0.1, // near
  500, // far
);
camera2.position.set(40, 10, 30);
camera2.lookAt(0, 5, 0);

const controls2 = new OrbitControls(camera2, view2Elem);
controls2.target.set(0, 5, 0);
controls2.update();

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

Вот функция, которая для данного элемента будет вычислять прямоугольник этого элемента, который перекрывает холст. Затем он установит плоскость отсечения (scissor) и область просмотра (fov) в этот прямоугольник и вернет aspect для этого размера.

function setScissorForElement(elem) {
  const canvasRect = canvas.getBoundingClientRect();
  const elemRect = elem.getBoundingClientRect();

  // вычисляем относительный прямоугольник холста
  const right = Math.min(elemRect.right, canvasRect.right) - canvasRect.left;
  const left = Math.max(0, elemRect.left - canvasRect.left);
  const bottom = Math.min(elemRect.bottom, canvasRect.bottom) - canvasRect.top;
  const top = Math.max(0, elemRect.top - canvasRect.top);

  const width = Math.min(canvasRect.width, right - left);
  const height = Math.min(canvasRect.height, bottom - top);

  //  установка области отсечения для рендеринга только на эту часть холста
  renderer.setScissor(left, top, width, height);
  renderer.setViewport(left, top, width, height);

  // return aspect
  return width / height;
}

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

  function render() {

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

+    resizeRendererToDisplaySize(renderer);
+
+    // включить область отсечения
+    renderer.setScissorTest(true);
+
+    // render the original view
+    {
+      const aspect = setScissorForElement(view1Elem);
+
+      // настроить камеру для этого соотношения сторон
+      camera.aspect = aspect;
+      camera.updateProjectionMatrix();
+      cameraHelper.update();
+
+      // не рисуем Helper камеры в исходном представлении
+      cameraHelper.visible = false;
+
+      scene.background.set(0x000000);
+
+      // отрисовка
+      renderer.render(scene, camera);
+    }
+
+    // отрисовка со 2-й камеры
+    {
+      const aspect = setScissorForElement(view2Elem);
+
+      // настроить камеру для этого соотношения сторон
+      camera2.aspect = aspect;
+      camera2.updateProjectionMatrix();
+
+      // рисуем Helper камеры во втором представлении
+      cameraHelper.visible = true;
+
+      scene.background.set(0x000040);
+
+      renderer.render(scene, camera2);
+    }

-    renderer.render(scene, camera);

    requestAnimationFrame(render);
  }

  requestAnimationFrame(render);
}

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

Мы также можем удалить наш updateCamera код, так как мы обновляем все в функции render.

-function updateCamera() {
-  camera.updateProjectionMatrix();
-}

const gui = new GUI();
-gui.add(camera, 'fov', 1, 180).onChange(updateCamera);
+gui.add(camera, 'fov', 1, 180);
const minMaxGUIHelper = new MinMaxGUIHelper(camera, 'near', 'far', 0.1);
-gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near').onChange(updateCamera);
-gui.add(minMaxGUIHelper, 'max', 0.1, 50, 0.1).name('far').onChange(updateCamera);
+gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near');
+gui.add(minMaxGUIHelper, 'max', 0.1, 50, 0.1).name('far');

И теперь вы можете использовать один вид, чтобы увидеть frustum другого.

Слева вы можете увидеть исходный вид, а справа вы можете увидеть вид, показывающий frustum камеры слева. Можно настроить near, far, fov и перемещать камеру с помощью мыши. Вы можете увидеть, как то, что внутри frustum, показаное справа, появляется на сцене слева.

Отрегулируйте near примерно до 20, и вы легко увидите, как передние объекты исчезают, поскольку их больше нет в усеченном конусе. Отрегулируйте far ниже примерно 35, и вы начнете видеть, что наземная плоскость исчезает, поскольку она больше не находится в не усеченной области.

Возникает вопрос, почему бы просто не установить near значение 0,0000000001 и far 10000000000000 или что-то в этом роде, чтобы вы могли видеть все? Причина в том, что ваш GPU имеет столько точности, чтобы решить, находится ли что-то впереди или позади чего-то другого. Эта точность распределена между near и far. Хуже того, по умолчанию точность закрытия камеры детализирована (резкое отсечение), а точность далеко от камеры - конечна. near медленно расширяется по мере приближения far.

Начиная с верхнего примера, давайте изменим код, вставив 20 сфер в ряд.

{
  const sphereRadius = 3;
  const sphereWidthDivisions = 32;
  const sphereHeightDivisions = 16;
  const sphereGeo = new THREE.SphereGeometry(sphereRadius, sphereWidthDivisions, sphereHeightDivisions);
  const numSpheres = 20;
  for (let i = 0; i < numSpheres; ++i) {
    const sphereMat = new THREE.MeshPhongMaterial();
    sphereMat.color.setHSL(i * .73, 1, 0.5);
    const mesh = new THREE.Mesh(sphereGeo, sphereMat);
    mesh.position.set(-sphereRadius - 1, sphereRadius + 2, i * sphereRadius * -2.2);
    scene.add(mesh);
  }
}

и давайте установим near = 0.00001

const fov = 45;
const aspect = 2;  // the canvas default
-const near = 0.1;
+const near = 0.00001;
const far = 100;
const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);

Нам также нужно немного подправить код графического интерфейса, чтобы позволить 0.00001, если значение редактируется

-gui.add(minMaxGUIHelper, 'min', 0.1, 50, 0.1).name('near').onChange(updateCamera);
+gui.add(minMaxGUIHelper, 'min', 0.00001, 50, 0.00001).name('near').onChange(updateCamera);

Как ты думаешь, что произойдет?

Это пример z fighting (сшивание), когда графический процессор на вашем компьютере не обладает достаточной точностью, чтобы определить, какие пиксели находятся спереди, а какие - сзади.

На тот случай, если проблема не отображается на вашей машине, вот что я вижу на своей машине

Одно из решений состоит в том, чтобы указать использование three.js другому методу для вычисления того, какие пиксели находятся спереди, а какие - сзади. Мы можем сделать это, включив, logarithmicDepthBuffer когда мы создаем WebGLRenderer

-const renderer = new THREE.WebGLRenderer({canvas});
+const renderer = new THREE.WebGLRenderer({
+  canvas,
+  logarithmicDepthBuffer: true,
+});

и с этим это может работать

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

Другая причина не выбирать это решение - оно может быть значительно медленнее, чем стандартное решение.

Даже при таком решении разрешение все еще ограничено. Сделайте near еще меньше или far больше, и вы в конечном итоге столкнетесь с теми же проблемами.

Это означает, что вы всегда должны прилагать усилия к тому, чтобы выбрать параметр near и far, которые соответствуют вашему варианту использования. Установите near как можно дальше от камеры, чтобы все не исчезло. Установите far как можно ближе к камере, чтобы все не исчезло. Если вы пытаетесь нарисовать гигантскую сцену и показать крупным планом чье-то лицо, чтобы вы могли видеть их ресницы, в то время как на заднем плане вы можете видеть весь путь в горы на расстоянии 50 километров, тогда вам нужно будет найти другое креативные решения, которые, возможно, мы рассмотрим позже. На данный момент, просто знайте, что вы должны позаботиться о том, чтобы выбрать подходящие near и far для ваших нужд.

2-ая ​​самая распространенная камера - OrthographicCamera. Вместо того, чтобы указать frustum он указывает прямоугольный паралелепипед (box) с параметрами left, right, top, bottom, near, и far. Поскольку он проецирует box, перспективы нет.

Давайте изменим приведенный выше пример 2 для использования OrthographicCamera в первом представлении.

Сначала давайте настроим OrthographicCamera.

const left = -1;
const right = 1;
const top = 1;
const bottom = -1;
const near = 5;
const far = 50;
const camera = new THREE.OrthographicCamera(left, right, top, bottom, near, far);
camera.zoom = 0.2;

Мы устанавливаем left и bottom = -1 и right и top = 1. Это сделало бы прямоугольник шириной 2 единицы и высотой 2 единицы, но мы собираемся отрегулировать left и top в соответствии со отношением сторон прямоугольника, к которому мы рисуем. Мы будем использовать свойство zoom, чтобы упростить настройку количества единиц, отображаемых камерой.

Давайте добавим настройки GUI для zoom

const gui = new GUI();
+gui.add(camera, 'zoom', 0.01, 1, 0.01).listen();

Вызовем listen говорящий lil-gui следить за изменениями. Потому что OrbitControls также может управлять масштабированием. Например, колесо прокрутки на мыши будет масштабироваться с помощью OrbitControls.

Наконец, нам просто нужно изменить часть, которая отображает левую сторону, чтобы обновить OrthographicCamera.

{
  const aspect = setScissorForElement(view1Elem);

  // обновить камеру для этого соотношения сторон
-  camera.aspect = aspect;
+  camera.left   = -aspect;
+  camera.right  =  aspect;
  camera.updateProjectionMatrix();
  cameraHelper.update();

  // не рисуем Helper камеры в исходном view
  cameraHelper.visible = false;

  scene.background.set(0x000000);
  renderer.render(scene, camera);
}

и теперь вы можете увидеть OrthographicCamera в работе.

OrthographicCamera чаще всего используется для рисования 2D-объектов. Вы решаете, сколько единиц вы хотите, чтобы камера показывала. Например, если вы хотите, чтобы один пиксель холста соответствовал одному элементу камеры, вы можете сделать что-то вроде:

Поместить начало координат в центр и иметь 1 пиксель = 1 единицу three.js что-то вроде:

camera.left = -canvas.width / 2;
camera.right = canvas.width / 2;
camera.top = canvas.heigth / 2;
camera.bottom = -canvas.height / 2;
camera.near = -1;
camera.far = 1;
camera.zoom = 1;

Или, если бы мы хотели, чтобы источник находился в верхнем левом углу, как 2D-холст, мы могли бы использовать это

camera.left = 0;
camera.right = canvas.width;
camera.top = 0;
camera.bottom = canvas.height;
camera.near = -1;
camera.far = 1;
camera.zoom = 1;

В этом случае верхний левый угол будет 0,0, как 2D холст

Давай попробуем! Сначала давайте настроим камеру

const left = 0;
const right = 300;  // default canvas size
const top = 0;
const bottom = 150;  // default canvas size
const near = -1;
const far = 1;
const camera = new THREE.OrthographicCamera(left, right, top, bottom, near, far);
camera.zoom = 1;

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

const loader = new THREE.TextureLoader();
const textures = [
  loader.load('../resources/images/flower-1.jpg'),
  loader.load('../resources/images/flower-2.jpg'),
  loader.load('../resources/images/flower-3.jpg'),
  loader.load('../resources/images/flower-4.jpg'),
  loader.load('../resources/images/flower-5.jpg'),
  loader.load('../resources/images/flower-6.jpg'),
];
const planeSize = 256;
const planeGeo = new THREE.PlaneGeometry(planeSize, planeSize);
const planes = textures.map((texture) => {
  const planePivot = new THREE.Object3D();
  scene.add(planePivot);
  texture.magFilter = THREE.NearestFilter;
  const planeMat = new THREE.MeshBasicMaterial({
    map: texture,
    side: THREE.DoubleSide,
  });
  const mesh = new THREE.Mesh(planeGeo, planeMat);
  planePivot.add(mesh);
  // move plane so top left corner is origin
  mesh.position.set(planeSize / 2, planeSize / 2, 0);
  return planePivot;
});

и нам нужно обновить камеру, если размер холста изменится.

function render() {

  if (resizeRendererToDisplaySize(renderer)) {
    camera.right = canvas.width;
    camera.bottom = canvas.height;
    camera.updateProjectionMatrix();
  }

  ...

planes - массив THREE.Mesh, по одному для каждой плоскости. Давайте переместим их в зависимости от времени.

function render(time) {
  time *= 0.001;  // конвертировать в секунды; 

  ...

  const distAcross = Math.max(20, canvas.width - planeSize);
  const distDown = Math.max(20, canvas.height - planeSize);

  // total distance to move across and back
  const xRange = distAcross * 2;
  const yRange = distDown * 2;
  const speed = 180;

  planes.forEach((plane, ndx) => {
    // compute a unique time for each plane
    const t = time * speed + ndx * 300;

    // get a value between 0 and range
    const xt = t % xRange;
    const yt = t % yRange;

    // set our position going forward if 0 to half of range
    // and backward if half of range to range
    const x = xt < distAcross ? xt : xRange - xt;
    const y = yt < distDown   ? yt : yRange - yt;

    plane.position.set(x, y, 0);
  });

  renderer.render(scene, camera);

И вы можете видеть, как изображения отскакивают от пикселей идеально по краям холста, используя пиксельную математику, как 2D холст

Другое распространенное использование OrthographicCamera для рисования - это отображение вверх, вниз, влево, вправо, спереди, сзади программ трехмерного моделирования или редактора игрового движка.

На скриншоте выше вы можете видеть 1 вид в перспективе и 3 вида в ортогональном виде.

Это основы камер. Мы рассмотрим несколько распространенных способов перемещения камер в других статьях. А пока давайте перейдем к теням.