Освещение и FOV
Этот файл описывает текущую работу системы освещения/FOV, её ужасные проблемы и идеи по улучшению. Пристегнитесь, вас ждёт история ошибок и капитуляции.
Следует отметить, что этот файл не заменяет комментарии в коде. Также, пожалуйста, поддерживайте его в актуальном состоянии, если изменяете код. Этот файл является скорее общим обзором того, как работает рендерер и какие техники используются. Мелкие детали реализации, вроде отсечения, объясняются в коде.
Также не ожидайте от этого документа ни хорошей структуры, ни приличного юмора. И не считайте меня гением. Ниже я упоминаю модные термины вроде VSM. Не думайте, что я понимаю математику за VSM.
Если вам особенно не терпится поэкспериментировать, многие из «неудачных экспериментов» имеют свой коммит в истории git в моей ветке. Так что если вы посмотрите на это и скажете «ого, PJB, да проблема же очевидна, смотри», есть хороший шанс, что вы можете перейти к коммиту с этой проблемой и поэкспериментировать.
Общий обзор.
Вот общий обзор (ого, вы прочитали заголовок!) шагов, которые проходит рендерер.
- Генерация геометрии окклюзии.
- Проекция глубины для теней и FOV.
- Отрисовка источников света (с применёнными тенями) в буфер освещения.
- Наложение FOV на буфер освещения.
- Размытие буфера освещения, чтобы свет «просачивался» в стены.
- (во время обычной отрисовки карты) применение освещения из буфера освещения.
- Применение FOV к финальному фреймбуферу.
Проецирование глубины.
Чтобы рассчитать тени и FOV, мы сначала вычисляем расстояние от источника света / камеры до ближайшего перекрывающего объекта, непрерывно вокруг него. Затем во время отрисовки собственно FOV/света мы можем проверить «перекрыт ли этот пиксель», определив позицию в карте теней и сравнив расстояние, записанное в этой карте теней, со значением, которое мы вычисляем как расстояние от света/камеры до пикселя.
Для каждого источника света/FOV мы рисуем геометрию, представляющую все видимые перекрывающие объекты, и сохраняем значения расстояния в текстуру. Изначальная версия основывалась на 4 вызовах отрисовки с разными перспективными проекциями, но новая версия основана на полярных координатах и использует 2 вызова отрисовки. Собственно карта теней является одномерной «линией», где каждый пиксель представляет свой угол, а содержимое пикселя представляет глубину: либо 2 канала (GL3, float, один канал для глубины и один канал, используемый VSM), либо 4 канала (GLES2 RGBA8, эмулирующие первое).
Что касается того, зачем нужны два вызова отрисовки, ответ таков: поскольку карта теней буквально круговая, почти гарантированно некоторые грани могут пересекать край. Вершинный шейдер обнаруживает это, определяя, превышает ли протяжённость грани 180 градусов (очевидно, невозможно для прямой линии), и «генерирует» дополнительную геометрию, сдвигая края грани влево или вправо в зависимости от прохода.
Также учтите, что аппаратная перспективная коррекция НЕ РАБОТАЕТ для такой круговой отрисовки. У первой реализации были «изогнутые» линии теней. Поэтому фрагментный шейдер определяет перспективную коррекцию вручную с помощью пересечений выровненных по осям линий с произвольным лучом (по сути abs(lineStart.x / rayNormal.x): используйте X для вертикали, Y для горизонтали). Поскольку gl_FragDepth недоступен в GLES2, Z-значения, используемые для внутреннего буфера глубины, всё ещё некорректны, но на практике это никогда не проявляется, потому что ошибка слишком мала, чтобы вызвать проблемы с сортировкой, а для собственно отрисовки теней используется цветовой буфер.
Верите или нет, но это всё ещё дешевле и значительно менее сложно, чем поэлементные операции, которых требовал сэмплер с 4 видами. Теперь выборка сводится просто к обращению к текстуре.
Следует отметить, что отрисовка света немедленно останавливается на всех гранях геометрии окклюзии. Это означает, что свет не «проникает» в стены. Смотрите шаг просачивания света выше. С FOV немного сложнее. FOV фактически вычисляется дважды: один раз с отсечением задних граней и один раз с отсечением передних граней. Это отсечение передних граней — финальный FOV, отрисовываемый поверх основной игры, чтобы вы всё ещё могли видеть «на» стены, но не за стены. FOV с отсечением задних граней используется для маскирования на буфер освещения.
Мы используем некоторый код, чтобы вставлять задние грани в геометрию окклюзии только в определённых местах, исходя из положения камеры и окружающих перекрывающих объектов. Это делает отсечение передних граней подходящим для отрисовки финального прохода FOV, не позволяя видеть стены за другими стенами.
Идея использования трёхмерной проекции глубины для расчёта теней в 2D была взята из Godot.
(Мягкие) алгоритмы теней сложны
SS14 частично использует Variance Shadow Maps (VSM) для некоторых операций (а именно для источников света и FOV освещения). VSM используется потому, что она «решает» проблемы алиасинга / смещения / акне теней. Она не используется для прямой реализации мягких теней, как предлагается в статье.
Я пытался реализовать мягкие тени с помощью VSM (размывая карту теней, как предлагается в статье выше), и результатом стало то, что тени просто безумно обрезались вокруг углов. Может, я что-то испортил, ну и ладно. В любом случае, это не дало бы того приятного смягчения теней в зависимости от расстояния, которое у нас есть сейчас (следующий абзац), так что, полагаю, всё вышло к лучшему.
Мягкие тени немного заморочены, но мне нравится, как они выглядят сейчас. В основном мы берём 7 точек на оси, перпендикулярной свету. Эти точки берутся на фиксированном расстоянии друг от друга. Затем мы используем их для вычисления расстояния до ближайшего перекрывающего объекта (взяв минимум по всем выборкам). В зависимости от этого расстояния мы можем сделать тени мягче, если они дальше от перекрывающего объекта, изменяя гауссовы веса, используемые для выборок. Так что если точка находится БЛИЗКО к перекрывающему объекту, выборки в центре будут иметь больший вес, и наоборот.
Мои попытки запустить VSM после применения гауссовых весов, к сожалению, оказались слишком дикими и не сработали. Не то чтобы я на них рассчитывал.
VSM не используется для финального FOV (вместо этого применяется обычная проверка расстояния со смещением; учтите, что проход FOV по буферу освещения ВСЁ ЖЕ использует VSM), потому что это вызывало серьёзные проблемы просачивания из-за задней части стен. Опять же, может, я что-то испортил. Хотя на 99% уверен, что это была не проблема просачивания, описанная в разделе 8.4.3 статьи выше (предлагаемое ими исправление ничего не дало). Вот просачивание FOV в действии. Синяя штука — это блюспейс, и вы НЕ должны его видеть.

Эти проблемы просачивания, возможно, просто особенность VSM, которая не является проблемой в 3D. Однако в 2D это определённо проблема. К счастью, из-за просачивания стен и прочего мы не замечаем проблему для обычных источников света и тому подобного. Может, я идиот и не понимаю, о чём говорю, это тоже вариант.
Забавный факт: изначально я рассматривал VSM потому, что не мог сделать обычный PCF приемлемым на вид. Я знаю, что напортачил с кодом шейдера в своих первых тестах PCF, поэтому половина выборок дублировалась. Я понял это только на полпути к реализации VSM. Молодец я!
Отрисовка света
Как упоминалось, весь свет сначала отрисовывается в закадровый фреймбуфер. Этот фреймбуфер может менять разрешение в зависимости от настроек графики, чтобы значительно снизить нагрузку на слабые GPU. Свет рисуется с немедленно применёнными тенями. Отрисовка света использует некую экспоненциальную-но-не совсем реалистичную-так-что-у-неё-всё-таки-есть-конец функцию освещения, которая выглядит вполне нормально. Применяется теневая окклюзия, применяется световая маска (для направленных источников света и прочего) и бам, да будет свет.
Этот закадровый фреймбуфер освещения является фреймбуфером с плавающей запятой для HDR. HDR выглядит хорошо.
Затем к фреймбуферу освещения применяется FOV. Это означает, что любой свет в области, которую вы не видите, «не существует». Это пригодится позже. Это FOV с отсечением задних граней (или, менее запутанно, FOV без отсечения передних граней с применённой VSM).
Фреймбуфер освещения после применения всего света.

Фреймбуфер освещения после применения FOV (центр окна).

Просачивание через стены
Если вы были внимательны (ладно, картинки выдали это), вы заметите, что стены на самом деле не освещены! Освещение стен выполняется многократным гауссовым размытием фреймбуфера освещения. Это размытие влияет только на пространство, поверх которого есть перекрывающий объект. Полы и прочее остаются нетронутыми.
Поскольку до этого к фреймбуферу освещения был применён FOV, вы не увидите свет по другую сторону стены! По-настоящему тёмные технические туннели!

Этот подход частично взят из Unitystation. Следует отметить, что Unitystation на самом деле всегда отрисовывает стены, даже если они находятся за несколькими слоями других стен. Вы не можете «видеть» эти стены, потому что они совершенно чёрные из-за отсутствия света поблизости (поскольку свет требует FOV). Однако это порождает очень странные лучи FOV, а также работает некорректно, если стены излучают свет (как это делают наши шлюзы…).
Отрисовка мира
Обычная отрисовка сущностей/мира имеет доступ к фреймбуферу освещения. Она берёт из него выборку, чтобы найти свет в точке, и смешивает с ним в шейдере по умолчанию.
Такие вещи, как лампы ЛКП, при отрисовке буквально просто игнорируют фреймбуфер освещения. Поскольку 99,9% реальных сценариев освещения темнее «1», это заставляет их заметно выделяться.
Финальный проход FOV
Мы делаем финальный проход FOV с FOV, отсечённым по передним граням, чтобы перекрыть всё оставшееся, например свет, просачивающийся на перекрытые стены, и механизмы, игнорирующие освещение.
Поскольку этот проход использует обычное проецирование глубины… вы можете видеть, как что-то просачивается сквозь заднюю часть стен под крутыми углами. Ага. Отлично. Это гораздо менее плохо, чем попытка с VSM, но было бы КРАЙНЕ заметно, если бы космический фон не был таким тусклым, как сейчас.
Мы также не отрисовываем мягкие края FOV. У меня не получилось сделать их красивыми. Извините.
Производительность
Производительность в целом вполне приличная. Используемый метод дёшев как для GPU, так и для CPU, и много текущих накладных расходов можно сократить, просто «улучшив реализацию». Несколько замечаний:
Исходная статья, на которую ссылались при реализации полярных координат использовала только один вызов отрисовки, а не два. Загвоздка в том, что для решения проблемы зацикливания она усложняла поэлементный шейдер ещё сильнее, требуя множественных выборок из карты теней. Ответ на этот компромисс, который выбрал я (20kdc, ответственный за реализацию полярных координат в SS14), задокументирован выше.
Отрисовка нескольких источников света в одних и тех же вызовах отрисовки (путём обработки нескольких источников света за один запуск фрагментного шейдера) также должна быть осуществима и, вероятно, значительно улучшила бы производительность GPU (особенно поскольку это помогло бы уменьшить использование пропускной способности фреймбуфера освещения и всё такое).
Использование исключительно GPU для расчёта глубины, как мы это делаем, является очень производительным. Именно так работает практически каждая 3D-игра, так что… Самая большая проблема в том, что это не очень точно, а неточности в 2D выглядят очень режуще (смещение…)
Инструменты производительности NSight, похоже, перестали работать у меня, а Intel GPA — сломанный беспорядок, так что я не могу это проверить, но гауссово размытие для просачивания через стены также может быть довольно затратным на неспециализированном оборудовании.
Баги, уродство и потенциальные улучшения.
“[буквально просто] Где PJB признаёт, что использование техник 3D-отбрасывания теней в 2D-игре, вероятно, не лучшая идея с высоты прошедшего времени”
Мягкие тени и мягкий FOV
Мягкий FOV полностью не реализован, потому что я просто не смог сделать его приличным и вроде как сдался.
Мягкие тени на источниках света выглядят так себе в лучшем случае (по крайней мере, по моим меркам).
Преимущество трёхмерной проекции глубины, которую мы используем, в том, что она очень дёшева для GPU и CPU. Недостаток в том, что заставить её выглядеть отлично… как минимум выше моего уровня навыков.
Более красиво выглядящий (но более дорогой) подход заключался бы в генерации геометрии окклюзии для каждого источника света отдельно. Это потребовало бы значительно больше усилий CPU. Делая это на CPU, мы точно знаем расстояние до источника света и можем генерировать прилично выглядящие полутени и всё такое. (примечание от 20kdc: если бы сглаживание не требовалось, вершинный шейдер GPU и использование буфера трафарета, вероятно, могли бы обеспечить здесь производительное решение, но это была бы гораздо более отдельная техника, и не такая настраиваемая, как то, что есть сейчас.)
Строго говоря, проход FOV с отсечением передних граней уже использует такую пользовательскую геометрию (общая геометрия окклюзии сделана так, что работает как обычно без отсечения передних граней, но С отсечением передних граней она спроектирована вокруг центра камеры). Эту логику можно было бы адаптировать для помощи здесь, хотя она может быть не особенно оптимальной.
Этот подход также в основном решил бы все проблемы смещения и просачивания, упомянутые выше.
Более производительный / точный проход просачивания через стены
Не могу отделаться от ощущения, что использование неточного гауссова размытия не является лучшим решением проблемы просачивания через стены.
Использование пользовательской геометрии окклюзии для каждого источника света может позволить выполнить некоторые вычисления на стороне CPU, чтобы не отрисовывать свет на стенах, если свет находится «по другую сторону стены» от камеры.
Если бы такой проход можно было реализовать, он был бы гораздо точнее гауссова размытия и, вероятно, производительнее на GPU.
Если хотите пример того, почему гауссов проход выглядит ужасно. Поставьте несколько окон рядом с какими-нибудь стенами. У окон и стен на самом деле разные уровни яркости (стены вынуждены искусственно создавать световую энергию в проходе размытия, чтобы компенсировать тот факт, что иначе они были бы вдвое тусклее). Это выглядит не очень хорошо, вообще.
Убрать неуклюжие углы низких стен.
Сейчас есть проблема: на прозрачные объекты, которые являются «частью» стены, всё ещё падают тени от соседней стены.

В ИДЕАЛЕ «треугольник» окклюзии здесь находился бы ПОД тайлом низкой стены и продолжался как обычно за ним. Однако на первый взгляд это нетривиально реализовать, но я думаю, что после недавнего разговора в Discord с Acruid мы придумали способ.
После расчёта обычной глубины мы делаем ЕЩЁ ОДИН проход вычислений, на этот раз по низким стенам. Этот проход имел бы похожие правила прохождения, как у обычных стен. Результат этого прохода — вычисление «ближайшего выхода из низкой стены после жёсткого перекрывающего объекта». Мы сохраняем это в другом цветовом канале.
Затем, при отрисовке FOV, мы пропускаем FOV, если точка окклюзии находится до «ближайшего выхода из низкой стены после жёсткого перекрывающего объекта». Таким образом, именно тайл низкой стены (поскольку он ДО выхода) не будет иметь FOV, но ТОЛЬКО если окклюзия также началась на этом тайле. Если у вас несколько слоёв низких стен (или формы вроде T-образных соединений, похожих на обычные стены), вы не окажетесь до ближайшего выхода.
Насколько я могу судить, нам нужно делать это только для FOV. Источникам света это не нужно, и мы вероятно можем обойтись выполнением размытия просачивания через стены на самой низкой стене вместо этого, с маской или чем-то подобным.
Ссылки
- https://learnopengl.com/Advanced-Lighting/Shadows/Shadow-Mapping
- https://www.gamasutra.com/blogs/RobWare/20180226/313491/Fast_2D_shadows_in_Unity_using_1D_shadow_mapping.php
- https://developer.nvidia.com/gpugems/gpugems3/part-ii-light-and-shadows/chapter-8-summed-area-variance-shadow-maps
- https://github.com/PJB3005/space-station-14/tree/20-02-10-shadows