Этот файл документирует текущую работу системы освещения/FOV, ужасные проблемы с ней и идеи по её улучшению. Пристегнитесь — вас ждёт история ошибок и капитуляции.
Стоит отметить, что этот файл не замена комментариям в коде. Пожалуйста, обновляйте его, если вы изменяете код. Этот файл — скорее высокоуровневый обзор работы рендерера и используемых техник. Детали реализации вроде culling’а объяснены в коде.
Также не ждите хорошей структуры и неплохого юмора от этого документа. И не считайте меня гением. Я упоминаю модные термины вроде VSM ниже. Не думайте, что я понимаю математику, стоящую за VSM.
Если вы чувствуете себя особенно авантюрно, многие «неудачные эксперименты» имеют какие-то коммиты в git-истории на моей ветке. Так что если вы посмотрите на это и скажете «ничего себе, PJB, проблема очевидна», есть большая вероятность, что вы можете перейти к коммиту с проблемой для эксперимента.
Высокоуровневый обзор
Вот высокоуровневый обзор (ого, вы прочитали заголовок!) шагов, через которые проходит рендерер.
- Генерация геометрии окклюзии.
- Проецирование глубины для теней и FOV.
- Отрисовка источников света (с наложенными тенями) в буфер освещения.
- Наложение FOV на буфер освещения.
- Размытие буфера освещения для «проникновения» в стены.
- (во время обычного рендеринга карты) применение освещения из буфера освещения.
- Применение FOV к финальному framebuffer’у.
Проецирование глубины
Для расчёта теней и FOV мы сначала вычисляем расстояние от источника света/камеры до ближайшего окклюдера непрерывно вокруг него. Затем при рендеринге фактического FOV/света мы можем проверить, «закрыт ли этот пиксель», определив позицию в карте теней и сравнив значение, записанное в этой карте теней, со значением, которое мы вычисляем для расстояния от источника света/камеры до пикселя.
Для каждого источника света/FOV мы рисуем геометрию, представляющую все видимые окклюдеры, и сохраняем значения расстояния в текстуру. Оригинальная версия этого была основана на 4 вызовах отрисовки с разными перспективными проекциями, но новая версия основана на полярных координатах, используя 2 вызова отрисовки. Фактическая карта теней — это 1D «линия», где каждый пиксель представляет собой разный угол, а содержимое пикселя представляет глубину, либо 2-канальную (GL3 float, один канал для глубины и один канал для VSM), либо 4-канальную (GLES2 RGBA8, эмулирующая предыдущую).
Что касается того, почему нужны два вызова отрисовки, ответ в том, что, поскольку карта теней буквально круглая, почти гарантировано, что некоторые грани могут пересекать край. Vertex shader обнаруживает это, определяя, превышает ли размах грани 180 градусов (очевидно, невозможно с прямой линией), и «генерирует» дополнительную геометрию, сдвигая края грани влево или вправо в зависимости от прохода.
Также обратите внимание, что аппаратная коррекция перспективы НЕ РАБОТАЕТ для кругового рендеринга, подобного этому. Первая реализация имела «гнутые» линии теней. Таким образом, fragment shader определяет коррекцию перспективы вручную с помощью трассировки пересечений выровненных по осям линий/произвольных лучей (по сути bs(lineStart.x / rayNormal.x): используйте X для вертикали, Y для горизонтали). Поскольку gl_FragDepth недоступен на GLES2, Z-значения, используемые для внутреннего буфера глубины, всё ещё некорректны, но на практике это никогда не проявляется, потому что ошибка слишком мала, чтобы вызвать проблемы сортировки, а цветовой буфер используется для фактического рендеринга теней.
Хотите верьте, хотите нет, но это всё ещё дешевле и значительно менее сложно, чем поэлементные операции, которые требовал 4-видовый сэмплер. Теперь сэмплинг — это просто поиск в текстуре.
Следует отметить, что рендеринг света немедленно останавливается на всех гранях геометрии окклюзии. Это означает, что свет не «проникает» в стены. См. шаг «проникновения света» выше. FOV немного сложнее. FOV вычисляется дважды: один раз с отсечением задних граней и один раз с отсечением передних граней. Это отсечение передних граней и есть окончательный FOV, отрисовываемый поверх основной игры, так что вы всё ещё можете видеть «на» стены, но не за стены. FOV с отсечением задних граней используется для маскировки на буфере освещения.
Мы используем некоторый код для вставки только задних граней в геометрию окклюзии в определённых местах в зависимости от положения камеры и окружающих окклюдеров. Это делает отсечение передних граней подходящим для рендеринга финального прохода FOV, не позволяя вам видеть стены-за-другими-стенами.
Идея использования 3D-проецирования глубины для расчёта теней в 2D взята из Godot.
Алгоритмы (мягких) теней — это сложно
SS14 частично использует Variance Shadow Maps (VSM) для некоторых операций (а именно, для источников света и FOV освещения). VSM используется, потому что он «решает» проблемы алиасинга/байасинга/акне теней. Он не используется для непосредственной реализации мягких теней, как предлагается в статье.
Я пытался реализовать мягкие тени с помощью VSM (размывая карту теней, как предложено в статье выше), и результаты были таковы, что тени просто безумно обрезались вокруг углов. Может быть, я что-то напутал, ну да ладно. В любом случае, это не дало бы хорошего смягчения теней в зависимости от расстояния, которое у нас есть сейчас (следующий абзац), так что, думаю, это сработало.
Мягкие тени немного сложны, но мне нравится, как они сейчас выглядят. В основном мы сэмплируем 7 точек на оси, перпендикулярной источнику света. Эти точки сэмплируются на фиксированном расстоянии друг от друга. Затем мы используем их для вычисления расстояния до ближайшего окклюдера (путём min’а всех сэмплов). В зависимости от этого расстояния мы можем делать тени мягче, если они находятся дальше от окклюдера, изменяя гауссовы веса, используемые для сэмплов. Итак, если точка БЛИЗКО к окклюдеру, сэмплы в центре будут иметь больший вес, и наоборот.
Мои попытки запустить VSM после применения гауссова взвешивания были, к сожалению, слишком безумными и не сработали. Не то чтобы я этого ожидал.
VSM не используется для финального FOV (вместо этого используется обычная смещённая проверка расстояния; обратите внимание, что проход FOV буфера освещения использует VSM), потому что это вызывало серьёзные проблемы просачивания из задней части стен. Опять же, возможно, я что-то напутал. Я на 99% уверен, что это была не проблема просачивания, описанная в разделе 8.4.3 вышеупомянутой статьи (предложенное ими исправление ничего не дало). Вот как выглядит просачивание FOV. Синее — это блюспейс, и вы НЕ должны его видеть.
Возможно, эти проблемы просачивания — это просто то, что делает VSM, и это не проблема в 3D. Однако это определённо проблема в 2D. К счастью, из-за просачивания через стены и тому подобного мы не замечаем проблемы для обычного освещения и так далее. Может быть, я идиот и понятия не имею, о чём говорю. Это тоже вариант.
Забавный факт: изначально я смотрел на VSM, потому что не мог заставить обычный PCF выглядеть приемлемо. Я знаю, что напутал с кодом шейдера в своих первых тестах PCF, так что половина сэмплов была дублирована. Я понял это только на полпути к реализации VSM. Молодец, я!
Рендеринг света
Как уже упоминалось, все источники света сначала рендерятся во внеэкранный framebuffer. Этот framebuffer может менять разрешение в зависимости от настроек графики, чтобы значительно снизить нагрузку на слабых GPU. Свет рисуется с немедленным наложением теней. Рендеринг света использует некоторую экспоненциальную, не совсем реалистичную, но с конечным радиусом функцию освещения, которая выглядит вполне прилично. Применяется окклюзия теней, применяется маска света (для направленного света и т.п.), и да будет свет.
Этот внеэкранный framebuffer освещения является framebuffer’ом с плавающей точкой для HDR. HDR выглядит хорошо.
Затем FOV применяется к framebuffer’у освещения. Это означает, что любой свет в области, которую вы не видите, «не существует». Это пригодится позже. Это FOV с отсечением задних граней (или, менее запутанно, не с отсечением передних граней, с применением VSM).
Framebuffer освещения после применения всех источников света.
Framebuffer освещения после применения FOV (центр окна).
Просачивание через стены
Если вы были внимательны (ладно, изображения выдали секрет), вы заметите, что стены на самом деле не освещены! Освещение стен выполняется путём многократного гауссова размытия framebuffer’а освещения. Это размытие затрагивает только пространство, над которым есть окклюдер. Полы и тому подобное не затрагиваются.
Поскольку FOV был применён к framebuffer’у освещения до этого, вы не увидите свет на другой стороне стены! Поистине тёмные туннели обслуживания!
Этот подход частично заимствован из Unitystation. Следует отметить, что Unitystation на самом деле всегда рендерит стены, даже если они находятся за несколькими слоями других стен. Вы не можете «видеть» эти стены, потому что они абсолютно чёрные из-за отсутствия близлежащего света (поскольку свет требует FOV). Однако это создаёт очень странные лучи FOV и также не работает корректно, если стены излучают свет (как наши шлюзы…).
Рендеринг мира
Обычный рендеринг сущностей/мира имеет доступ к framebuffer’у освещения. Он сэмплирует его, чтобы найти освещение в точке, и смешивает с ним в стандартном шейдере.
Такие вещи, как лампочки APC, буквально игнорируют framebuffer освещения при рендеринге. Поскольку 99.9% реальных сценариев освещения темнее «1», это заставляет их заметно выделяться.
Финальный проход FOV
Мы выполняем финальный проход FOV с отсечением передних граней, чтобы заблокировать всё остальное, например, просачивание света на закрытые стены и игнорирующие освещение механизмы.
Поскольку этот проход использует обычное проецирование глубины… вы можете видеть, как объекты просвечивают сквозь заднюю часть стен под крутыми углами. Ага. Замечательно. Это гораздо менее плохо, чем попытка с VSM, но было бы ЧРЕЗВЫЧАЙНО заметно, если бы фон космоса не был таким тусклым.
Мы также не рендерим мягкие края FOV. Я не смог заставить их выглядеть хорошо. Извините.
Производительность
Производительность в целом вполне приличная. Используемый метод дёшев как для GPU, так и для CPU, и многие текущие накладные расходы можно сократить, просто «улучшив реализацию». Некоторые моменты:
Оригинальная статья, на которую ссылались при реализации полярных координат использовала только один вызов отрисовки, а не два. Загвоздка в том, что для решения проблемы заворачивания она усложняла поэлементный шейдер ещё больше, требуя несколько сэмплов карты теней. Ответ на этот компромисс, который я (20kdc, ответственный за реализацию полярных координат в SS14) выбрал, задокументирован выше.
Рендеринг нескольких источников света в одном и том же вызове отрисовки (путём обработки нескольких источников света в одном запуске fragment shader’а) также должен быть возможен и, вероятно, значительно улучшит производительность GPU (особенно за счёт снижения использования пропускной способности для framebuffer’а освещения и всего такого).
Исключительно GPU-расчёт глубины, как мы делаем, очень производителен. Так работают практически все 3D-игры, поэтому… Самая большая проблема в том, что он не очень точен, и неточности выглядят очень резкими в 2D (байасинг…).
Инструменты производительности NSight, кажется, перестали работать для меня, а Intel GPA — сломанный беспорядок, поэтому я не могу это проверить, но гауссово размытие для просачивания через стены может быть довольно затратным на не-выделенном оборудовании.
Баги, уродство и потенциальные улучшения
«[буквально только что] Где PJB признаёт, что использование 3D-техник shadowcasting’а в 2D-игре, вероятно, не очень хорошая идея в ретроспективе»
Мягкие тени и мягкий FOV
Мягкий FOV полностью не реализован, потому что я просто не смог заставить его выглядеть прилично и в какой-то степени сдался.
Мягкие тени от источников света выглядят так себе в лучшем случае (по крайней мере, по моим стандартам).
Преимущество 3D-проецирования глубины, как мы используем, в том, что оно очень дёшево для GPU и CPU. Недостаток в том, что заставить его выглядеть отлично… выше моего уровня мастерства, по крайней мере.
Более красивый (но более дорогой) подход заключался бы в custom-генерации геометрии окклюзии для каждого источника света. Это потребовало бы значительно больше усилий от CPU. При выполнении этого на CPU мы точно знаем расстояние до источника света и можем генерировать прилично выглядящие полутени и тому подобное. (примечание от 20kdc: если бы не нужен был антиалиасинг, GPU vertex shader и использование буфера трафарета, вероятно, могли бы дать производительную реализацию, но это был бы гораздо более отдельный метод и не такой настраиваемый, как текущий.)
Строго говоря, проход FOV с отсечением передних граней уже использует такую custom-геометрию (общая геометрия окклюзии сделана так, что работает как обычно без отсечения передних граней, но С отсечением передних граней она спроектирована вокруг центра камеры). Эту логику можно было бы адаптировать для помощи здесь, хотя это может быть не особенно оптимально.
Этот подход также в основном решил бы все проблемы байасинга и просачивания, упомянутые выше.
Более производительный/точный проход просачивания через стены
Я не могу избавиться от чувства, что использование неточного гауссова размытия — не лучшее решение для проблемы просачивания через стены.
Использование custom-геометрии окклюзии для каждого источника света может позволить выполнять некоторые вычисления на стороне CPU, чтобы не рендерить на стены, если свет был бы «по другую сторону стены» от камеры.
Если бы такой проход можно было реализовать, он был бы гораздо точнее гауссова размытия и, вероятно, более производительным на GPU.
Если вам нужен пример, почему гауссов проход выглядит ужасно. Поместите несколько окон рядом с некоторыми стенами. Окна и стены на самом деле имеют разный уровень яркости (стены принудительно искусственно создают световую энергию в проходе размытия, чтобы компенсировать тот факт, что иначе они были бы вдвое тусклее). Выглядит это не очень, совсем.
Убрать неудобные углы низких стен
В настоящее время существует проблема, что прозрачные объекты, которые являются «частью» стены, всё равно получают тени от соседней стены.
В ИДЕАЛЕ, треугольник окклюзии здесь должен находиться ПОД тайлом низкой стены и продолжаться как обычно за ним. Однако это нетривиально реализовать на первый взгляд, но, кажется, после недавнего чата в Discord с Acruid мы нашли способ.
После расчёта обычной глубины мы делаем ЕЩЁ ОДИН проход расчёта, на этот раз для низких стен. Этот проход будет иметь аналогичные правила прохождения, как и для обычных стен. Результатом этого прохода будет расчёт «ближайшего выхода из низкой стены после жёсткого окклюдера». Мы сохраняем это в другом цветовом канале.
Затем, при рендеринге FOV, мы пропускаем FOV только если точка окклюзии находится перед «ближайшим выходом из низкой стены после жёсткого окклюдера». Таким образом, точный тайл низкой стены (потому что он ДО выхода) не будет иметь FOV, но ТОЛЬКО если окклюзия началась также на этом тайле. Если у вас есть несколько слоёв низких стен (или формы вроде t-образных перекрёстков, как у обычных стен), вы не будете перед ближайшим выходом.
Насколько я могу судить, это нужно делать только для FOV. Свету это не понадобится, и мы, вероятно, можем обойтись размытием просачивания через стены на самой низкой стене с помощью маски или чего-то подобного.
Ссылки
Последнее изменение 21 июня 2026 г.