Часть 1

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

«Это просто задача отсечения»

Свет внезапно выключается. Вы сидите в огромном конференц-зале. Кто-то включает проектор. На экране сам Майкл Абраш! Похоже, это знаменитый доклад Quake Postmortem с GDC 1997.

Он говорит о порталах.

Мы берём плоскости отсечения, ограничивающие максимальный объём видимого, и отсекаем их по всё более дальним порталам, пока они не пропадут

Затем он рисует вот такую схему:

The camera leaf is marked with a triangle. The first trivially visible is marked with “P”.
Лист с камерой помечен треугольником. Первый тривиально видимый лист помечен буквой «P».

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

The visible volume visible through the first two portals.
Объём, видимый через первые два портала.

Закрашенная область видима из первых двух порталов. Она становится плотнее. Далее он рисует две новые линии через грани второго и третьего порталов:

The visible volume visible through the second and third portals.
Объём, видимый через второй и третий порталы.

Лист, в который ведёт второй портал, помечен буквой P как видимый. Однако две закрашенные области не пересекаются, поэтому третий портал невидим из первого, и процесс завершается:

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

Гром аплодисментов. Сидящий рядом с вами человек встаёт и начинает топать ногами от восхищения. Так, он что, поёт We Are the Champions?

Впрочем, достаточно. Вернёмся в настоящее.

Так неужели это всё? Майкл сказал, что «реализация чуть сложнее», но добавил, что «это всего лишь задача отсечения». Разберёмся, что это значит.

Отсечение по порталам в 2D

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

Отсечение происходит, когда портал видим только частично. Это показано на схеме ниже. Есть три портала: исходный портал начального листа, портал прохода, через который мы смотрим, и целевой портал, невидимость которого мы по возможности хотим доказать.

Defining the visible volume. The original target portal is clipped to fit the volume visible through the source and pass portals. The orange and teal lines are oriented so that the source and pass portals are on their back and front sides, respectively.
Определяем видимый объём. Изначальный целевой портал усечён, чтобы уместиться в объём, видимый через исходный и проходной порталы. Оранжевая и бирюзовая линии направлены так, что исходный и проходной порталы находятся, соответственно, на их задней и передней частях.

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

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

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

Как найти разделительные плоскости

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

На диаграмме ниже показано, как это можно сделать. Сначала выбираем грань исходного портала (отрезок AB). Затем выбираем вершину проходного портала (C). Создаём плоскость по трём точкам AB и C.

Fitting a plane to the points A, B, and C between the source and pass portals. Anything behind the plane will be clipped away in the blue target portal.
Создаём плоскость по точкам AB и C между исходным и проходным порталом. Всё, что находится в синем целевом портале за плоскостью, будет усечено.

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

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

Another separating plane with a different choice of AB and C. Note how the target portal has already shrunk.
Ещё одна разделительная плоскость с другими выбранными AB и C. Обратите внимание, что целевой портал уже усечён.

Иногда точка C может оказываться позади исходного портала. Это не ошибка, но может происходить, если проходной портал только частично находится перед исходным:

An edge case. If C is behind the source portal then the plane normal must be flipped.
Пограничный случай. Если C находится позади исходного портала, то нормаль плоскости должна поменять направление.

В этом случае достаточно сменить направление нормали плоскости.

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

Перед усечением есть ещё одна (опциональная) смена направления нормали плоскости.

Это может сбить с толку, но я объясню, как это работает.

Разделительные плоскости на Python

Ниже показан код. Два вложенных цикла for — главное изобретение алгоритма. По крайней мере, мне не удалось упоминаний этого в работе Теллера. Мой код на Python довольно близок к коду на C++ в ericw-tools. Аргумент otherside принимает значение true, когда исходный и проходной портал меняются местами.

# Выбор разделительной плоскости и усечение

def clip_to_separators(
  first_poly: Winding,
  first_plane: Plane,
  second_poly: Winding,
  clipped_poly: Winding,
  otherside=False,
) -> Winding:
  clipped = clipped_poly

  for i in range(len(first_poly)):
    j = (i + 1) % len(first_poly)
    A = first_poly[i]
    B = first_poly[j]
    AB = B - A

    # Пробуем другие точки C на втором портале
    for k, C in enumerate(second_poly):
      # Проверяем, на какой стороне первого портала находится точка C
      d = first_plane.distance_to(C)
      if d < -ON_EPSILON:
        # Разделительная плоскость по определению должна иметь второй портал
        # на своей передней части. Здесь C находится позади первого портала,
        # так что это будет не так после выполнения ниже
        #   normal = cross(AB, AC)
        # и нам придётся позже поменять направление нормали.
        flip_towards_first = True
      elif d > ON_EPSILON:
        flip_towards_first = False
      else:
        continue  # Точка C находится на плоскости первого многоугольника

      AC = C - A
      normal = np.cross(AB, AC)
      mag = np.linalg.norm(normal)

      if mag < ON_EPSILON:
        continue  # Порталы могут иметь общие вершины, так что плоскость отсутствует
      normal /= mag

      plane = Plane(normal, normal.dot(C))

      if flip_towards_first:
        plane = -plane

      # Проверяем, является ли плоскость разделительной
      if not test_if_points_in_front(second_poly, plane):
        continue

      # Флаг 'otherside' устанавливается, если исходный и проходной порталы поменялись местами.
      # В таком случае second_poly == source_poly, поэтому нормаль плоскости направлена
      # на исходный, а не на проходной портал!
      # Ниже мы переворачиваем нормаль, чтобы усекалась нужная сторона.
      if otherside:
        plane = -plane

      clipped = clip_winding(clipped, plane)

      if not clipped:
        return None

  return clipped

Усечение и исходного портала

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

Уровень проверок

Первый

Второй

Усечение

1

Исходный

Проходной

Целевой

2

Проходной

Исходный

Целевой

3

Целевой

Проходной

Исходный

4

Проходной

Целевой

Исходный

Куча плоскостей и усечений! Но нам всё ещё нужно обработать всю первую карту Quake с помощью vis.py, потому что он очень медленный.

Соединяем всё вместе при помощи потока по порталам

Итак, что у нас пока есть:

  • мы создали граф с направленными порталами,

  • вычислили список потенциально видимых листьев для каждого портала

  • научились определять видимость через последовательность трёх порталов.

Всё почти готово! Мы объединим эти три ингредиента, чтобы получить окончательную видимость между листьями. Воспользуемся тем же способом, что и в проходе грубых расчётов: обходом в глубину из конечного листа портала, рекурсивно посещающим соседние листья через порталы. Только дополним это более точными проверками видимости.

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

Рекурсивное усечение на примере

Подведём итог описанному ранее: у нас есть три портала (исходный, проходной и целевой) Исходный портал — первый из видимых, в нём начинается обход. Проходной портал представляет собой отверстие, которое точно видимо из исходного портала. Через два этих портала проверяется видимость целевого портала. Это выполняется отсечением.

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

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

Усечение последовательности порталов

Шаг 1. Мы вычисляем видимые листья для красного исходного портала справа. Сначала мы заходим в лист 8 — конечный лист портала. В первой итерации проходной портал пока не выбирается, поэтому мы прямиком входим в лист 7.
Шаг 1. Мы вычисляем видимые листья для красного исходного портала справа. Сначала мы заходим в лист 8 — конечный лист портала. В первой итерации проходной портал пока не выбирается, поэтому мы прямиком входим в лист 7.
Шаг 2. Старый целевой портал становится новым проходным порталом. Мы усекаем целевой портал, делая его немного меньше, и входим в лист 6.
Шаг 2. Старый целевой портал становится новым проходным порталом. Мы усекаем целевой портал, делая его немного меньше, и входим в лист 6.
Шаг 3. В листе 6 портал между листьями 6 и 5 полностью находится внутри видимого объёма. Усекать его не нужно.
Шаг 3. В листе 6 портал между листьями 6 и 5 полностью находится внутри видимого объёма. Усекать его не нужно.
Шаг 4. Мы немного усекаем многоугольник целевого портала. Появляется лист 3.
Шаг 4. Мы немного усекаем многоугольник целевого портала. Появляется лист 3.
Шаг 5. Целевой портал полностью усечён. Эта ветвь рекурсии завершена.
Шаг 5. Целевой портал полностью усечён. Эта ветвь рекурсии завершена.
Шаг 6. Извлекаем данные из стека и исследуем портал между листьями 6 и 4, но сразу же отсекаем его. Портал слева в листе 8 находится в одной плоскости с исходным порталом, поэтому он уже был отброшен на этапе грубого расчёта видимости.
Шаг 6. Извлекаем данные из стека и исследуем портал между листьями 6 и 4, но сразу же отсекаем его. Портал слева в листе 8 находится в одной плоскости с исходным порталом, поэтому он уже был отброшен на этапе грубого расчёта видимости.
Шаг 7. Обход завершён. Мы пометили листья 8, 7, 6, 5 и 3 как потенциально видимые из исходного портала.
Шаг 7. Обход завершён. Мы пометили листья 8765 и 3 как потенциально видимые из исходного портала.

Движемся по порталам на Python

Как я и говорил, обход происходит так же, как в случае грубого расчёта видимости. Код выглядит примерно так:

# Упрощённый набросок кода полного расчёта видимости.
# Работать не будет!

# Вызывается один раз для каждого портала 'Ps'
def portal_flow(Ps: Portal):

  # Функция рекурсивного посещения листьев
  def leaf_flow(leafnum: int, mightsee: np.ndarray)
    Ps.vis[leafnum] = True  # Mark leaf visible

    for pt in leaf.portals:
      Pt = leaves[pt]

      # Может ли предыдущий портал потенциально видеть целевой лист?
      if not mightsee[Pt.leaf]:
        continue

      # [Выполняем усечение порталов]

      # Если портал 'Pt' не был отсечён, выполняем рекурсию через него
      if Pt is not None:
        leaf_flow(...)
    
  # Начинаем обход с конечного листа Ps
  leaf_flow(Ps.leaf, Ps.mightsee)

# Вызываем для каждого портала
for prt in portals:
  portal_flow(prt)

# Массив 'vis' каждого портала заполнен.

Функция portal_flow начинает обход точно так же, как base_portal_flow делала это в грубом расчёте. Стек вызовов выглядит примерно так.

Результаты определения видимости зависят от всего обойденного на данный момент пути. Именно поэтому в наброске кода leaf_flow получает аргумент mightsee — отфильтрованный массив видимости листьев из порталов.

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

шаг  лист  mightsee (x = видимый)
----  ----- -------------------------
1.    8     [ x x x x x x x x . . . ] 
2.    7     [ x x x x x x x . . . . ]
3.    6     [ x x x x x x . . . . . ]
4.    5     [ x x x . x . . . . . . ]
5.    3     [ x x x . . . . . . . . ]
              1 2 3 4 5 6 7 8 9 1011   лист

Обратите внимание, что на последнем шаге в листе 3 мы не можем вернуться в лист 5 , потому что в mightsee он больше не помечен как видимый. Рекурсия может завершиться, только если массив станет настолько разреженным, что больше ни в какие порталы нельзя будет войти. Другой способ — полное отсечение портала.

Полная реализация

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

# Рекурсивное отсечение порталов vis.py

# Распределяем матрицу видимости портал->лист
portal_vis = np.zeros((num_portals, num_leaves), dtype=bool)

# Назначаем каждому порталу строку в матрице
for pi, Pi in enumerate(portals):
  Pi.vis = portal_vis[pi, :]

# Мы можем немного ускорить обработку, если будем знать, какие порталы
# уже достигли своей окончательной видимости
portal_done = np.zeros(num_portals, dtype=bool)

def portal_flow(ps: int, Ps: Portal):
  def leaf_flow(
    leafnum: int,
    mightsee: np.ndarray,
    src_poly: Winding,
    pass_plane: Plane,
    pass_poly: Union[Winding, None],
  ):
    Ps.vis[leafnum] = True

    # Проверяем каждый портал, ведущий из этого листа
    for pt in leaves[leafnum].portals:
      Pt = portals[pt]  # Целевой портал-кандидат

      # Может ли предыдущий портал потенциально видеть целевой лист?
      if not mightsee[Pt.leaf]:
        continue

      # Используем массив окончательной видимости, если портал был обработан
      if portal_done[pt]:
        test = Pt.vis
      else:
        test = Pt.mightsee

      # Отфильтровываем все листья, которые нельзя увидеть из предыдущих порталов
      might = np.bitwise_and(mightsee, test)

      # Пропускаем, если мы можем видеть только листья, которые уже точно видимы
      if not any(np.bitwise_and(might, np.bitwise_not(Ps.vis))):
        continue

      # Усекаем целевой портал плоскостью исходного портала
      if not (target_poly := clip_winding(Pt.winding, Ps.plane)):
        continue

      # Непосредственные соседи не требуют дополнительных проверок
      if pass_poly is None:
        leaf_flow(Pt.leaf, might, src_poly, Pt.plane, target_poly)
        continue

      # Убеждаемся, что целевой и исходный порталы находятся, соответственно, впереди и позади
      # от проходного портала

      if not (target_poly := clip_winding(target_poly, pass_plane)):
        continue

      if not (src_clipped := clip_winding(src_poly, -pass_plane)):
        continue

      # Усекаем целевой и исходный многоугольники разделительными плоскостями

      if test_level > 0:
        target_poly = clip_to_separators(
          src_clipped, Ps.plane, pass_poly, target_poly)
        if not target_poly:
          continue

      if test_level > 1:
        target_poly = clip_to_separators(
          pass_poly, pass_plane, src_clipped, target_poly, backside=True
        )
        if not target_poly:
          continue

      if test_level > 2:
        src_clipped = clip_to_separators(
          target_poly, Pt.plane, pass_poly, src_clipped)
        if not src_clipped:
          continue

      if test_level > 3:
        src_clipped = clip_to_separators(
          pass_poly, pass_plane, target_poly, src_clipped, backside=True
        )
        if not src_clipped:
          continue

      # Если все проверки пройдены, то мы входим в лист позади портала 'Pt'.
      # Старый целевой портал становится новым целевым. Список 'might'
      # теперь ещё больше фильтруется. Многоугольники 'src_clipped' и 'target_poly'
      # могут быть усечены и стать меньше.

      leaf_flow(Pt.leaf, might, src_clipped, Pt.plane, target_poly)

  leaf_flow(Ps.leaf, Ps.mightsee, Ps.winding, Ps.plane, None)
  portal_done[ps] = True

Этот код непосредственно взят из vis.py с добавлением пары комментариев. В конечном итоге он оказался ненамного проще, чем исходная функция RecursiveLeafFlow.

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

Сначала обрабатываем более простые порталы

Теперь давайте поговорим о важной оптимизации производительности. Мы вызываем portal_flow для каждого портала по порядку «сложности». В этом случае «сложность» портала означает, как много листьев потенциально оказались видимы на этапе грубого расчёта. Порталы с малыми значениями обрабатываются первыми.

Это та часть приведённого выше кода, в которой используется строгий массив vis вместо более свободных результатов mightsee:

      # Используем массив окончательной видимости, если портал был обработан
      if portal_done[pt]:
        test = Pt.vis
      else:
        test = Pt.mightsee

      # Отфильтровываем все листья, которые нельзя увидеть из предыдущих порталов
      might = np.bitwise_and(mightsee, test)

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

Видимость листьев через порталы

Каждый лист может видеть другие листья только через свои порталы. Поэтому potentially visible set листа — это объединение PVS исходящих из него порталов.

A leaf’s potentially visible set is the union of its portals’ sets.

Ниже показана промежуточная матрица portal_vis. В ней символ x обозначает видимость.

Портал
 1 . x x . x x x x . . x 
 2 x . . . . . . . . . . 
 3 . . x x x x x x . . x 
 4 x x . . . . . . . . . 
 5 . . . . x x x x x . x 
 6 x x x . . . . . . . . 
 7 . . . x . x x x . . x 
 8 . x x . . . . . . . . 
 9 . . . . . x x x . . . 
10 . . x x . . . . . . . 
11 . . . . . x x x x . x 
12 x x x . x . . . . . . 
13 . . . . . . x x x . x 
14 x x x x x x . . . . . 
15 . . . . . . . x x . x 
16 x x x x x x x . . . . 
17 . . . . . . . . . . x 
18 x x x . x x x x . . . 
19 . . . . . . . . x x . 
20 . . x . x x x x . . . 
21 . . . . . . . . . x x 
22 . . . . . . . x x . . 
23 . . . . . . . x . . x 
24 . . . . . . . . x x . 
   1 2 3 4 5 6 7 8 91011 лист


Получившаяся видимость между листьями представлена квадратной булевой матрицей, которую я назвал final_vis:

лист
 1 x x x . x x x x . . x 
 2 x x x x x x x x . . x 
 3 x x x x x x x x x . x 
 4 . x x x . x x x . . . 
 5 x x x . x x x x x . x 
 6 x x x x x x x x x . x 
 7 x x x x x x x x x . x 
 8 x x x x x x x x x x x 
 9 . . x . x x x x x x x 
10 . . . . . . . x x x x 
11 x x x . x x x x x x x 
   1 2 3 4 5 6 7 8 91011 лист

Окончательная матрица видимости между листьями.

Объединение множеств, представленных в виде булевых массивов или битовых векторов, можно выполнить логической операцией OR. Мы обходим в цикле строки portal_vis и выполняем OR результатов для каждого портала со строками каждого листа в массиве final_vis, записываемого в файл BSP.

# Вычисляем окончательную видимость для каждого листа
final_vis = np.zeros((num_leaves, num_leaves), dtype=bool)

for li, leaf in enumerate(leaves):
  for pi in leaf.portals:
    np.bitwise_or(final_vis[li], portal_vis[pi], out=final_vis[li])
  final_vis[li, li] = True  # leaf is visible to itself

В игре

Инструмент vis записывает окончательную матрицу видимости между листьями напрямую в уже скомпилированный файл mapname.bsp. Код сериализации сначала упаковывает строки матрицы в последовательность битовых векторов, а затем сжимает каждый кодированием RLE, пропускающим повторяющиеся нулевые байты.

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

Наслаждаемся результатами

Очень здорово наблюдать, как несколько порталов, загруженных в программу на Python

попадают в реальную игру!

The PVS working in the Quakespasm source port. The rest of the map is hidden when moving right, as expected.
Работа PVS в порте исходников Quakespasm. Остальная часть карты, как и можно ожидать, скрывается при перемещении вправо.

Также я записал низкокачественное видео PVS примера карты. К сожалению, я пока не обработал карту E1M1 целиком при помощи vis.py, потому что это очень медленно.

Ускоряем код

Должен признать, что vis.py очень медленный. Можно внести ещё некоторые оптимизации.

Во-первых, добавить многопроцессорную обработку. То есть каждый портал будет обрабатываться параллельно. Это уже было сделано в исходном vis, работавшем на четырёхъядерном сервере DEC Alpha. На Python это можно сделать при помощи пула воркеров стандартной библиотеки и массива общей памяти, передаваемого как хранилище массивов numpy с помощью np.frombuffer

В файлах vis.cc и flow.cc ericw-tool мы можем найти и другие трюки:

  • Тесты ограничивающих сфер порталов, чтобы не проверять все их точки.

  • Ускорение фильтрации массивов mightsee. Время от времени во время обхода для определения точной видимости каждый портал тестируется с текущей последовательностью исходного-проходного порталов. Такой подход частично схож с более простыми PVS из Doom 3, о которых мы расскажем в следующем разделе.

  • Кэширование: в исходном коде игры усечение разделительными плоскостями повторно использует некоторые плоскости из предыдущих проверок. Не совсем понятно, как это работает.

В реализациях на C и C++ управление стеком рекурсии выполняется вручную. Однако я не уверен, будет ли это выгодно в коде на Python.

Оглядываясь назад

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

Хотя в диссертации Сета Теллера за 1992 год уже был представлен способ генерации статической предварительно вычисленной видимости (и в работе Джона Эйри за 1990 год), в Quake он появился не сразу. Изначально разработчики решили, что BSP-дерево будет выполнять рендеринг спереди назад при помощи лучевого дерева, но в конечном итоге было выбрано разбиение мира на PVS и выполнение проверки коллизий. BSP-дерево можно обходить в порядке видимости, но это свойство вообще не требовалось, потому что рендерер линий сканирования в Quake всё равно ресолвил видимость собственным способом.

PVS долгое время использовались и в сиквелах Quake, а также в других сериях игр, например, в Half-Life и Counter-Strike. Даже сегодня можно вычислять нечто подобное в Unreal Engine 5.

PVS — хорошая идея?

С точки зрения рабочего процесса PVS неидеальны. Дизайнерам уровней всегда приходится ждать, прежде чем они увидят скорость работы карты в игре. Ещё им приходится думать о не относящихся к ним технических концепциях наподобие hint brush. Кроме того, инструменты Quake ожидают, что карта будет идеально герметичной, разделённой на внутреннюю и внешнюю части. Если это не так, то возникают ошибки с «протечками», из-за которых вычисление PVS завершается сбоями. Думаю, о них никто не ностальгирует.

С точки зрения маркетинга такой подход обеспечивает высокое качество графики. Очевидно, разработчики Quake хотели, чтобы игра стала хитом благодаря своим красивым 3D-мирам с наложенными текстурами. Стабильная частота кадров помогает продать эту иллюзию. Так как инженерная работа — это всегда поиск компромиссов, мирам пришлось быть статичными, а дизайнерам уровней — терпеливыми. Возможно, низкая скорость итераций сделала дизайн игры менее амбициозным, ухудшив продукт. Как бы то ни было, разработка игры сопровождалась проблемами, поэтому непонятно, улучшила ли бы ситуацию более высокая скорость компиляции карт.

Что, если мы будем вычислять PVS очень быстро?

Очевидно, в Doom 3 разработчики начали использовать размещаемые вручную порталы с тестированием в среде исполнения и PVS, создаваемые во время загрузки уровня. Проход грубого расчёта видимости схож с алгоритмом в Quake, но проход точного расчёта выполняется быстрее.

Сначала код предварительно вычисляет, какие порталы потенциально видимы через любую пару исходного и проходного порталов. В коде этот массив пар порталов называется passage; он вычисляется при помощи разделительных плоскостей и усечения, как в Quake. Когда настаёт время рекурсивной заливки графа порталов, никакого усечения уже не требуется. Вместо этого в массивах passage выполняется проверка того, можно ли увидеть целевой портал через текущий исходный и проходной портал. В процессе рекурсии множество потенциально видимых порталов становится разреженнее, как это было с массивом mightsee.

Весь процесс в целом выполняется очень быстро. Я поискал онлайн отладочные сообщения логов и нашёл результат за 2007 год для карты game/erebus1:

512 bytes passage memory used to build PVS
1 msec to calculate PVS
36 areas
60 portals
6 areas visible on average
288 bytes PVS data

Всего 60 порталов! Для сравнения: первая карта Quake содержит 3077 порталов. Миллисекундное ожидание в процессе загрузки лучше, чем часы во время компиляции карты. Разумеется, дизайнеры уровней теперь отвечают за размещение порталов и герметичность уровней.

Было бы очень любопытно узнать, как решение из Doom 3 работало бы в Quake. В сложных геометриях всё равно бы пришлось усекать множество порталов, здесь бы помогло PVS и уменьшение общего количества порталов. Это был бы отличный исследовательский ретро-проект. В конце статьи приведены релевантные ссылки на исходный код.

Итог

Лично для меня написание vis.py оказалось очень информативным процессом. Мне пришлось учиться работать с уравнениями плоскостей, считывать порталы и прокачать мои навыки отладки. Необходимость удобства чтения кода и его описания заставила меня сделать многие подробности более понятными, чего бы не случилось, если бы я просто читал какой-то код.

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

Ресурсы для разработки

Репозиторий vis.py выложен на GitHub. Локальная копия: vis_py.zip.

Немного ссылок на реализации порталов и PVS в Doom 3. В коде используется термин area вместо leaf, потому что им обозначаются не всегда выпуклые ячейки.

Данные карт и порталов

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

Пример карты

Первые комнаты E1M1

Здесь я поломал скорость дверей, простите.

Комментарии (0)