Создание порталов в three.js

В этой статье я покажу вам все, что вам нужно знать для создания порталов в 3D-сценах с использованием фреймворка three.js.

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

Концептуально портал - это своего рода «окно» в сцене, которое отображает вид в другую часть виртуального мира. Портал имеет два «конца», один из которых является «локальным» или «исходным» концом, близким к текущему местоположению игрока, и «удаленный» или «конечный» конец, который представляет собой место, видимое через окно. На каждом конце есть отдельная камера. Для целей этой статьи «основной» камерой является локальная камера - та, которая визуализирует сцену, окружающую портал. Другая камера, которую мы назовем «виртуальной» камерой, представляет вид внутри портала.

Организация кода

Поскольку мы можем захотеть иметь более одного портала в сцене, нам понадобится способ организации порталов, которые в данный момент находятся в поле зрения. Я справляюсь с этим, создавая ActivePortal класс, содержащий детали для одного портала, а затем поддерживаю массив этих объектов. Класс ActivePortal имеет несколько ключевых методов:

  • Методы установки для настройки параметров портала, включая исходную позицию, позицию назначения и размер апертуры.
  • update() метод, который вызывается каждый кадр перед рендерингом для вычисления местоположения окна на экране.
  • Метод render(), который вызывается для каждого портала после рендеринга основной сцены.

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

Синхронизированные камеры

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

Начнем с координат каждого конца (обратите внимание, что весь код в этой статье написан на TypeScript):

class ActivePortal {
  public readonly sourcePosition = new Vector3();
  public readonly destinationPosition = new Vector3();
}

Чтобы вычислить положение виртуальной камеры, мы вычисляем вектор разницы между ближним и дальним концом портала и применяем это различие к виртуальной камере относительно основной камеры. Этот расчет выполняется при каждом обновлении. Обратите внимание, что необходимо установить оба параметра position и lookAt.

this.differential
  .copy(this.destinationPosition)
  .sub(this.sourcePosition);

Размер диафрагмы

Еще нам нужно знать, насколько большим будет портал. Теоретически портал не обязательно должен быть «плоским», он может иметь любую трехмерную форму. Однако для большинства целей вам понадобится ровная поверхность. В моем коде я просто использую Box3 для обозначения апертуры портала с одним из размеров, установленным на ноль.

Визуализация диафрагмы

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

this.geometry = new BoxBufferGeometry(
  this.apertureSize.x,
  this.apertureSize.y,
  this.apertureSize.z
);
this.material = new MeshBasicMaterial({
  colorWrite: false,
});
this.mesh = new Mesh(this.geometry, this.material);
this.mesh.geometry.computeBoundingSphere();
this.mesh.frustumCulled = true;
this.mesh.matrixAutoUpdate = false;
this.mesh.renderOrder = 2;
this.mesh.visible = true;
this.mesh.name = 'Portal';

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

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

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

Поскольку мы собираемся визуализировать несколько порталов, нам понадобится способ различать их на уровне пикселей. Это можно сделать, присвоив каждому активному порталу уникальный целочисленный индекс и записав этот индекс в буфер шаблонов. Таким образом, первый портал запишет в шаблон значение 1, второй портал использует значение 2 и так далее. Значение 0 представляет области экрана, где нет портала. Теоретически это позволяет использовать до 255 порталов, хотя маловероятно, что вам понадобится такое количество, потому что рендеринг будет очень медленным.

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

Хотя у three.js есть способ указать материал для записи в буфер трафарета, мне нужен был более точный контроль, чем позволяет three.js. Вместо этого я использую обратные вызовы для непосредственной установки состояния OpenGL, что также оказывается проще и понятнее, чем использование API three.js:

this.mesh.onBeforeRender = renderer => {
  if (this.mesh.visible) {
    const gl = renderer.getContext();
    gl.enable(gl.STENCIL_TEST);
    gl.stencilMask(0xff);
    gl.stencilFunc(gl.ALWAYS, this.stencilIndex, 0xff);
    gl.stencilOp(gl.KEEP, gl.KEEP, gl.REPLACE);
  }
};
this.mesh.onAfterRender = renderer => {
  if (this.mesh.visible) {
    // Set everything back to the way it was before
    const gl = renderer.getContext();
    gl.disable(gl.STENCIL_TEST);
    gl.stencilMask(0);
    gl.stencilFunc(gl.ALWAYS, 1, 0xff);
    gl.stencilOp(gl.KEEP, gl.KEEP, gl.KEEP);
  }
};

Вычисление границ экрана портала

После рендеринга основной сцены нам нужно рендерить каждый портал как отдельный проход. Во время этого этапа мы будем визуализировать небольшую часть экрана. Для этого требуется вычислить двухмерную ограничивающую рамку апертуры. Это вычисление происходит в методе update() перед рендерингом.

const widthHalf = screenSize.width / 2;
const heightHalf = screenSize.height / 2;
this.nearDepth = Infinity;
// Start by making the bounding box empty
this.portalScreenRect.makeEmpty();
const addPortalPoint = (x: number, y: number, z: number) => {
  // Project point to screen space from aperture space
  this.worldPt.set(x, y, z);
  this.worldPt.applyMatrix4(this.mesh.matrixWorld);
  this.worldPt.project(camera);
  // Convert to pixels. Note rounding to prevent jitter.
  this.screenPt.x = Math.round(
    this.worldPt.x * widthHalf + widthHalf);
  this.screenPt.y = Math.round(
    -(this.worldPt.y * heightHalf) + heightHalf);
  // Expand the bounding box to include the point.
  this.portalScreenRect.expandByPoint(this.screenPt);
  
  // Also track the depth of the nearest aperture corner.  
  // This is used to sort the portals by depth.
  this.worldPt.applyMatrix4(camera.projectionMatrixInverse);
  this.nearDepth = Math.min(this.nearDepth, -this.worldPt.z);
};
// Compute all 8 points of the aperture box and expand the rect.
addPortalPoint(-apertureSize.x, -apertureSize.y, -apertureSize.z);
addPortalPoint(apertureSize.x, -apertureSize.y, -apertureSize.z);
addPortalPoint(-apertureSize.x, apertureSize.y, -apertureSize.z);
addPortalPoint(apertureSize.x, apertureSize.y, -apertureSize.z);
addPortalPoint(-apertureSize.x, -apertureSize.y, apertureSize.z);
addPortalPoint(apertureSize.x, -apertureSize.y, apertureSize.z);
addPortalPoint(-apertureSize.x, apertureSize.y, apertureSize.z);
addPortalPoint(apertureSize.x, apertureSize.y, apertureSize.z);
// Add an extra 2 pixels around the edge of the screen rect to
// account for rounding errors.
this.portalScreenRect.expandByScalar(2);

На этом этапе мы можем определить, действительно ли портал отображается на экране:

this.mainScreenRect.min.set(0, 0);
this.mainScreenRect.max.copy(screenSize);
this.isOnscreen = this.mainScreenRect.intersectsBox(
  this.portalScreenRect);
this.mesh.visible = this.isOnscreen;

Если портала нет на экране, мы можем пропустить его рендеринг.

Затем нам нужно преобразовать portalScreenRect в форму, которую можно использовать для настройки смещения обзора камеры и прямоугольника обрезки. Этот расчет также можно выполнить update() методом портала:

// Compute viewport coordinates for scissor (Vector4).
this.portalViewport.x = this.portalScreenRect.min.x;
this.portalViewport.y = 
  screenSize.height - this.portalScreenRect.max.y;
this.portalViewport.z = 
  this.portalScreenRect.max.x - this.portalScreenRect.min.x;
this.portalViewport.w = 
  this.portalScreenRect.max.y - this.portalScreenRect.min.y;
this.portalCamera.setViewOffset(
  screenSize.width,
  screenSize.height,
  this.portalScreenRect.min.x,
  this.portalScreenRect.min.y,
  this.portalScreenRect.max.x - this.portalScreenRect.min.x,
  this.portalScreenRect.max.y - this.portalScreenRect.min.y
);

Управление плоскостями отсечения

Одна из распространенных проблем с порталами связана с объектами, которые находятся между камерой и порталом. Для основной камеры мы хотим, чтобы эти объекты отображались поверх портала. А вот с виртуальной камерой мы этого не делаем. Любые объекты, которые находятся между виртуальной камерой и удаленным концом портала, должны быть исключены из сцены.

Есть несколько способов сделать это - простой, но несколько неточный способ сделать это - отрегулировать ближнюю плоскость отсечения виртуальной камеры в зависимости от расстояния до портала (которое мы уже вычислили).

Однако наиболее надежный способ сделать это - использовать функцию плоскости отсечения в three.js.

Однако есть одна загвоздка: количество плоскостей отсечения запекается в каждом шейдере three.js во время компиляции. Если вы измените количество плоскостей отсечения, он должен будет перекомпилировать каждый шейдер, что будет очень медленно.

Способ обойти это - всегда иметь одинаковое количество плоскостей отсечения, даже в основной сцене. Для этого у основной сцены будет «фиктивная» плоскость отсечения, расположенная достаточно далеко, чтобы она ничего не отсекала.

Отображение содержимого портала

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

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

Основной цикл рендеринга выглядит так:

this.renderer.render(this.scene, this.camera);
this.renderer.getScissor(this.saveScissor);
this.renderer.getViewport(this.saveViewport);
this.renderer.setScissorTest(true);
this.saveClipPlane.copy(this.renderer.clippingPlanes[0]);
portals.forEach(portal => {
  portal.render(this.renderer);
});
this.renderer.setScissorTest(false);
this.renderer.setScissor(this.saveScissor);
this.renderer.setViewport(this.saveViewport);
this.renderer.clippingPlanes[0].copy(this.saveClipPlane);

А метод render портала выглядит так:

renderer.clippingPlanes[0].copy(this.clippingPlane);
renderer.autoClearStencil = false;
renderer.setScissor(this.portalViewport);
renderer.setViewport(this.portalViewport);
this.mesh.visible = false;
const gl = renderer.getContext();
renderer.autoClearColor = false;
gl.enable(gl.STENCIL_TEST);
gl.stencilFunc(gl.EQUAL, this.stencilIndex, 0xff);
gl.stencilMask(0);
gl.stencilOp(gl.KEEP, gl.KEEP, gl.KEEP);
renderer.render(this.destinationScene, this.portalCamera);
gl.disable(gl.STENCIL_TEST);
this.mesh.visible = this.isOnscreen;

Еще раз обратите внимание, что мы делаем вызовы непосредственно в GL для установки параметров буфера трафарета. Причина в том, что three.js не позволяет глобально установить параметры трафарета для всех материалов; к счастью, three.js не затрагивает состояние трафарета, если не существует материала, для которого stencilWrite установлено значение true, чего не происходит ни в одном из моих материалов.

Шансы и концы

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

Это почти все. Удачи с вашими порталами!

Смотрите также