Шел уже ХХ-й месяц, как я без работы все еще ковыряю свой Vulkan-рендер для движка X-Ray OGSR.
Когда я начинал делать мод, я чуть меньше разбирался в том, как проще. Начитавшись про плюсы forward-рендеринга, и так как мне хотелось живого цикла дня и ночи — чтобы солнце медленно ползло по небу, а тени так же медленно ползли по стенам — начал делать его.
Сразу честно, потому что это важно для всей истории. Классический рендер S.T.A.L.K.E.R. — это R4, deferred. У него тени уже неплохие и плавные, задача была сделать как минимум не хуже. Но я делал свой рендер с нуля и бессознательно пошёл в forward. Не потому что это «правильнее», а потому что хотелось наворачивать технологии, экспериментировать, и forward казался более гибким полем для этого. О том, что именно forward усложнит работу с тенями (и не только с ними), я тогда не до конца думал.
В deferred тень от солнца считается один раз — в проходе освещения, по G-буферу. В forward её приходится сэмплить в каждом шейдере геометрии. А у меня их шесть: terrain, lmap, vlit, скелетка (NPC), деревья, трава. Так что, как только я доделал тени, я понял, что ощутимо проигрываю по производительности.
Что делать? Попробовал отсекать лишние тени, делать ЛОДы для теней, и все равно до производительности классического рендера не дотягивал процентов 20. Что дальше? Можно было бы, конечно, сказать: «Включайте апскейлеры, которые я доделаю, и будет вам счастье», — но это не путь воина. )
Попробовал сделать кеширование, и да — FPS сразу улетел в x2,5 от классического R4. Но появился неприятный момент, всё сломалось : ) кеш дал тик (тени замирают и скачком перерисовываются при движении солнца).

Но тут я понял что вот это то куда нужно копать чтоб сделать круто но как сделать и кеширование и динамику одноврменно на одном экране?

Небольшое отступление. Раз уж зашла речь про forward, давайте разберёмся честно: что это вообще за выбор и почему я о нём не пожалел (хотя и поплатился).
Коротко про оба подхода:
Deferred (как R4). Сцена сначала рисуется в «толстый» G-буфер: для каждого пикселя экрана сохраняются нормаль, альбедо, глубина, спекуляр и так далее. Потом отдельный проход освещения идёт по этому буферу и считает свет строго один раз на пиксель.

А я сделал именно наивный. В первой версии мой рендер тестировал 16 динамических источников света против каждого фрагмента вообще без отбраковки (culling). Каждый пиксель честно прогонял 16 проверок дальности и 16 функций затухания — хотя реально на него влияли от силы 1–3 лампы. В deferred такой проблемы нет by design: там свет считается строго один раз на пиксель. Вот он, структурный разрыв с R4, который я создал себе сам.
После долгого и активного гугления «в лоб» напрашивались два выхода:
Переписать всё в deferred. Похоронить весь forward-эксперимент и городить G-буфер. Не хотелось — это означало бы отказ от всего, ради чего я в forward и шёл.
Сделать forward умным. То есть превратить его в Forward+ (clustered/tiled forward).
Что такое Forward+ и что я для этого сделал
Идея Forward+ проста: не проверять каждый пиксель против всех ламп, а заранее разложить источники света по ячейкам экрана с помощью compute-прохода, чтобы затем каждый пиксель перебирал только те лампы, которые реально достают до его ячейки. Это не способ уменьшить число ламп — наоборот: становится настолько дёшево, что можно легко поднять лимит и зажечь хоть сотни источников.
Вот как я это реализовал:
3D-сетка фрокселей (froxels): Разрешение $16 \times 9 \times 24 = 3456 ячеек. Она независима от разрешения экрана (тайлы масштабируются). Срезы по глубине сделаны экспоненциальными (по схеме Olsson / Doom:
slice = log2(zview) * scale + bias), чтобы детализация ячеек шла по логарифму глубины, как и положено.Compute-шейдер: Один compute-dispatch, работающий без атомиков. Один поток на фроксель: он строит свой AABB (Axis-Aligned Bounding Box) во view-пространстве, тестирует сферу каждого источника на пересечение (sphere-vs-AABB) и пишет индексы попавших ламп в свой фиксированный участок буфера (
[ci 64 .. ci 64 + 64]). Так как участки памяти жестко разделены и не пересекаются, атомарные операции не нужны.Фрагментный шейдер: Фрагмент вычисляет индекс своей ячейки на основе
gl_FragCoordи view-глубины, после чего перебирает только её локальный список. Лимит источников благодаря этому успешно поднят с 16 до 256.Отладочный хитмап (
r_clustered_debug): Реализовал раскраску пикселей по числу ламп в ячейке. Костёр — одна синяя ячейка, гроздь ламп — красно-жёлтые зоны, включенный фонарик — синяя сфера вокруг игрока. Это очень помогает убедиться, что биннинг корректен, ещё до того, как начинаешь доверять финальной картинке.Проверка корректности: Кластерный путь — это надмножество «правильных» ламп (грубый тест пересечения сферы и AABB даёт пару ложных попаданий на границах, но их затем начисто добивает функция затухания). Поэтому при количестве ламп $\le 16$ картинка обязана быть идентична старому, наивному пути, просто работать быстрее. Так и вышло: A/B тест прямо в игре показал, что при переключении
r_clustered 1иr_clustered 0визуал неотличим — фонарик, костры и лампы светят абсолютно одинаково.
Урок, который я вынес: Forward — это не «устаревший» выбор, а осознанный инженерный шаг, как в Doom или Unreal Engine 5. Но он жизнеспособен только до тех пор, пока ты не остаёшься наивным. Наивный forward проигрывает deferred структурно; Forward+ закрывает этот разрыв без тотального переписывания архитектуры. А раз так — ставка на forward оправдана, и можно со спокойной душой навешивать на него всё остальное, включая VSM (Virtual Shadow Maps).
2. Почему каскады — это дорого
Стандартное решение для солнца — Cascaded Shadow Maps (CSM). Идея простая: близко к камере нужна высокая детализация тени, далеко — низкая. Поэтому сцена разбивается на несколько «каскадов» (вложенных областей разного размера), и для каждого рендерится своя карта теней. Именно так работает и классический R4.
И вот тут скрывался важный момент, который я сначала недооценил. Каскады в R4 плавные. Но плавные они не благодаря магии, а за счёт грубой силы: R4 перерисовывает карту теней каждый кадр с честной привязкой текстурной сетки к мировым координатам (world-anchored texel snap). Солнце сдвинулось на чуть-чуть? Не беда, движок всё равно перерисовывает всё заново, просто под новым углом. Картинка получается плавной, но обходится это очень дорого.
Я изначально портировал ровно этот подход. Сделал два каскада $4096 \times 4096$ (на 25 и 60 метров вокруг камеры) плюс дальнюю карту теней. Тени получились плавными и чёткими, но профайлер быстро показал реальную цену этой красоты:
Shadow/Casc0 (ближний 4096²) = 3.0–3.15 мс Shadow/Casc1 (2048²) ~ 0.0–1.1 мс Shadow/CascCull (отбраковка) = 0.01 мс ← почти бесплатно!
Ближний каскад в одиночку стоил больше 3 мс — это был самый дорогой проход во всём кадре, который умудрялся обходить по времени отрисовку всей основной геометрии сцены (~2.5 мс).
И здесь важен ключевой вывод: раз отбраковка геометрии (culling) обходится в микроскопические 0.01 мс, значит, проблема не в том, что мы скармливаем конвейеру «лишние» объекты. Дело в самой растеризации: каждый кадр мы заставляем GPU заново прогонять тонны полигонов через конвейер, чтобы получить точно такую же карту глубины, ведь 99% геометрии на сцене за этот кадр вообще никак не изменилось.
Вот это бессмысленное «перерисовываем одно и то же каждый кадр» — и есть главная архитектурная боль. И именно из неё растут корни проблемы, которую нужно было решать.
Логика очевидная: если сцена статична и солнце не движется — зачем перерисовывать тень? Давайте отрендерим её один раз и будем переиспользовать, а перерисовывать только когда что‑то реально поменялось.
3. Первая идея: а давайте закешируем каскад
Я так и сделал — добавил кеш каскада (r_shadow_casc_cache): карта теней замораживается и перерисовывается только при достаточном смещении камеры или солнца.
Для статичной сцены это сработало великолепно. Стоишь на месте — стоимость каскада падает почти в ноль.
Но как только солнце начинает двигаться, всплывает проблема, которую кешем не решить:
— Порог инвалидации стоит, например, на 0.05° смещения солнца.
— Солнце ползёт. Накапливается 0.05° — кеш сбрасывается — вся карта 4096² перерисовывается одним кадром.
— Casc0 подскакивает с ~0 до 3.3 мс в этот кадр.
— Глаз видит: тень замерла → дёрнулась → замерла → дёрнулась.
Вот тут стоит остановиться и признать иронию: смоотность, которая в R4 была из коробки, я сломал собственными руками — ровно в тот момент, когда полез её оптимизировать. R4 платил полную цену каждый кадр и был плавным; я попытался не платить — и получил тик. Это и есть тик. И тут — главное прозрение всей истории:
> Кеш «всё или ничего» не может дать плавное движущееся солнце. Плавность принципиально требует перепроецирования каждый кадр. А значит, надо не выбирать между «перерисовать всё» и «не перерисовывать», а перерисовывать только то, что реально изменилось и реально видно — мелкими кусочками, каждый кадр.
Можно понизить порог инвалидации до микроскопического — но тогда мы перерисовываем 4096² почти каждый кадр и возвращаемся к исходной цене. Тупик.
Проблема не в кешировании. Проблема в гранулярности. Каскад — это монолит. А нужен механизм, который работает на уровне маленьких кусочков карты теней. Именно это и делает Virtual Shadow Maps.
4. Что такое Virtual Shadow Maps (VSM)
Эту технологию придумали в Epic Games для Unreal Engine 5. Базовая идея выглядит так:
Гигантское виртуальное разрешение. Представляем виртуальную карту теней настолько огромной и детальной, что на экране каждый пиксель тени получает примерно один тексель карты. В моей реализации — это клипмапа (clipmap) из 6 уровней с виртуальным разрешением $4096 \times 4096$ на каждый уровень.
Разбиение на страницы. Эту огромную виртуальную карту мы нарезаем на небольшие страницы (pages) размером $128 \times 128$ текселей. Всего получается 6144 виртуальные страницы.
Выделение по требованию (Sparse Allocation). Физической памяти под всю эту гигантскую карту в видеопамяти нет, да она и не нужна. Каждый кадр мы анализируем экран и спрашиваем: «Какие конкретно страницы сейчас видны хотя бы одному пикселю на экране?» Физическая память выделяется строго под них.
Кеширование в мировом пространстве. Мы рендерим геометрию только в те страницы, которые необходимы. Главное для нашей задачи — они кешируются в мировых координатах. Страница, привязанная к конкретной точке игрового мира, остаётся валидной между кадрами до тех пор, пока внутри неё ничего не изменилось.
Что это даёт для движущегося солнца? Солнце слегка сдвинулось $\rightarrow$ инвалидируется и перерисовывается не весь монолитный каскад, а только часть видимых страниц, причём этот процесс размазан во времени (round-robin: обновляется примерно $1/N$ страниц за кадр). Если игрок стоит на месте и солнце замерло — перерисовывается ровно ноль страниц. Картинка остаётся плавной, а нагрузка на GPU — ничтожной.
Важное уточнение для честности. В Unreal Engine 5 технология VSM создавалась в тандеме с Nanite — там геометрия рендерится прямо в страницы виртуальной памяти практически бесплатно. У меня нет ни Nanite, ни меш-шейдеров (целевая аудитория модов на S.T.A.L.K.E.R. часто сидит на слабом железе, а mesh shaders требуют видеокарт уровня NVIDIA Turing+ или AMD RDNA2+).
Поэтому путь рендера в страницы у меня реализован на ручном compute-биннинге, что по меркам Epic считается их официальным «медленным» путём. Но я и не пытаюсь обогнать связку UE5 + Nanite. Моя цель скромнее и конкретнее: получить плавное живое солнце по цене обычного каскада на относительно слабом железе.
5. Как это легло на forward-рендер X-Ray
Большинство материалов по VSM написаны под deferred-архитектуру (тот же UE5 — deferred-движок). У меня же чистокровный forward, да ещё и интегрированный в движок 2007 года. Перенос концепции был нетривиальным. На сегодняшний день пайплайн VSM за один кадр у меня выглядит следующим образом:
1. MARK — Разметка видимых страниц
Это compute-проход по буферу глубины. Для каждого видимого пикселя на экране мы реконструируем его мировую позицию $\rightarrow$ определяем нужный уровень клипмапы и индекс страницы $\rightarrow$ через atomicOr помечаем её в битмаске «нужных» страниц.
Объекты, находящиеся off-screen или за спиной игрока, вообще не отмечаются в страницах-приёмниках. Это даёт бесплатный колоссальный выигрыш по сравнению со стандартным каскадом, который послушно рисует полный ортографический бокс на 360° вокруг камеры, независимо от того, куда смотрит игрок.
Forward-бонус (возврат налогов): Проходу MARK жизненно необходим готовый буфер глубины сцены — и он у меня уже был! В forward-рендере я и так делаю depth prepass (отдельный ранний проход, рисующий только глубину), чтобы механизм early-Z на уровне железа отсекал переотрисовку в тяжелом и дорогом цветовом проходе. Этот препасс я добавлял по сугубо forward-причинам — для борьбы с диким овердро (overdraw) при расчёте освещения. И здесь VSM достался мне абсолютно бесплатно: тот же буфер глубины, который спасает forward от овердро, оказался ровно тем, что нужно для разметки виртуальных страниц. Редкий случай, когда архитектурный «налог» forward-рендера окупился сам собой.
2. ALLOC — Выделение физической памяти
На этом этапе запускается один вычислительный поток на каждую виртуальную страницу. Если страница помечена как видимая и нужная, мы атомарно резервируем под неё физический слот в общем атласе текстур и записываем эту связь в таблицу страниц: pageTable[virtual] = slot.
3. BIN — Распределение кастеров по страницам
Для каждого кастера (объекта, отбрасывающего тень) мы вычисляем, в какие именно резидентные физические страницы попадает его проекция в световом пространстве солнца. На основе этого формируются списки отрисовки и генерируются indirect-команды. Это и есть наш аналог Nanite, реализованный на compute-шейдерах.
4. RENDER — Отрисовка «грязных» страниц
Мы растеризуем геометрию исключительно в те страницы, данные в которых изменились или устарели (используя loadOp = LOAD, очищая только «грязные» участки). Кешированные и не изменившиеся страницы GPU вообще не трогает.
5. RESOLVE — Сборка экранной маски теней
Здесь происходит главный архитектурный сдвиг. Вместо того чтобы каждый отдельный шейдер-приёмник геометрии лез со своими выборками в текстурный атлас, отдельный compute-проход вычисляет затенение сразу для всего экрана в единую маску в screen-space (формат RGBA16F) и применяет к ней temporal-сглаживание.
После этого каждый из 6 наших шейдеров-приёмников (terrain, lmap, vlit, skinned, tree, grass) делает буквально одну строчку кода:
High-level shader language
float shadow = texture(uVsmMask, screenUV).r;
Второй forward-бонус: Помните, во вступлении forward отомстил нам тем, что сэмплить тень приходилось внутри шести разных шейдеров геометрии вместо одного прохода в deferred? На этапе RESOLVE мы полностью возвращаем этот долг. Шейдер вычисляет тень ровно один раз на весь экран — точно так же, как это происходит в deferred-проходе освещения по G-буферу. Шесть материальных шейдеров больше не занимаются дорогим расчётом теней, а просто читают готовую маску. По сути, мне удалось точечно воспроизвести главное deferred-удобство для теней, вообще не заводя громоздкий G-буфер.
Архитектурные нюансы и решение багов
Временное (Temporal) сглаживание без буфера скоростей. Сглаживание маски теней во времени у меня работает без классических motion vectors, которых в моём forward-рендере на данный момент просто нет. Репроекция истории кадра идёт через буфер глубины и матрицу предыдущего кадра (
prevViewProj).Важный извлечённый урок: Самый дорогой и неуловимый баг всего VSM был связан именно с этим. Проблема «дрожащих световых шафтов» оказалась вызвана не математикой теней, а тем, что temporal-орбита репроецировала мир, который слегка дрожал от антиалиасингового джиттера (jitter). Баг вылечился репроекцией чистого, неджиттеренного центра кадра, в то время как джиттер теперь применяется строго к финальному сэмплу.
Честная мировая привязка (World-anchored). Матрицу вида солнца я строю с виртуальным «глазом», жестко привязанным к началу координат мира, а не к движущейся камере игрока. Благодаря этому координаты $X$ и $Y$ в световом пространстве становятся независимы от перемещений камеры $\rightarrow$ страницы намертво привязываются к точкам игрового мира, что и делает возможным их долгосрочное кеширование между кадрами.
Тороидальная резидентность. Реализована вообще без использования тяжелых хеш-таблиц и алгоритмов типа free-list. Взятие индекса по модулю само по себе работает как вытеснение старых страниц. Никаких коллизий не возникает, пока рабочее окно укладывается в рамки 32 страниц по ширине. Решение получилось дешёвым, быстрым и надёжным.
Разделение атласа на статику и динамику. Физический атлас жестко поделен на две независимые зоны:
Статический атлас (неподвижная геометрия уровней + статические деревья) — агрессивно кешируется и почти не нагружает систему.
Динамический атлас (скелетная анимация NPC, динамическая трава) — честно перерисовывается по необходимости.
В финальном проходе RESOLVE шейдер сэмплит оба атласа одновременно и выбирает минимальное значение глубины (
min(depth_static, depth_dynamic)). Результат получается визуально идентичным, как если бы мы использовали один общий гигантский атлас, но по производительности это колоссальный выигрыш.
6. Война за производительность: с +6 мс до +0.5 мс
Когда VSM впервые заработал «честно» (когда мы просто перерисовываем вообще всё каждый кадр), он оказался на $\sim 6$ мс тяжелее классического каскада. Это был приговор: никто в здравом уме не включит фичу, которая роняет FPS в полтора раза. Ниже — хронология оптимизаций, каждая со своим «зачем» и «на сколько».
6.1. Перестать платить дважды за каскад (−2.7 мс)
Первый и самый стыдный баг: при включённом VSM старый каскад продолжал послушно рендериться каждый кадр. Материальные шейдеры-приёмники уже читали готовую VSM-маску, а старый каскадный проход всё равно молотил впустую. Добавил гейт vsmActive вокруг растеризации солнца
SunShadow: 3.29 мс → 0.55 мс
Минус 2.7 мс чистого времени просто за то, что я перестал делать двойную работу.
Forward-налог: оружие в руках (HUD Weapon). Один читатель старых карт солнца у меня всё-таки остался — это модель оружия в руках игрока. И это чисто forward-специфичная заноза. В forward-рендере оружие рисуется тем же пайплайном, что и весь остальной мир, но в своей собственной, намеренно искажённой проекции. Его «мировые» координаты — фейковые (сплющенные по глубине), чтобы ствол визуально красиво держался у камеры и никогда не проваливался сквозь геометрию стен.
Из-за этого единая screen-space VSM-маска для оружия считалась бы некорректно: её мировая реконструкция к фейковым координатам пушки неприменима. Пришлось оставить оружие на старом каскадном сэмплинге. Под VSM оно получает слегка устаревший, «замороженный» каскад, что на глаз абсолютно незаметно.
В deferred-архитектуре такого спецслучая, скорее всего, не возникло бы: там тень накладывается на финальный экранный результат, а не пересчитывается из локальных координат меша. Мелочь, но очень показательная — forward постоянно подсовывает такие частные случаи там, где deferred обходится одним общим путём.
6.2. Исправление барьера: DEPTH $\rightarrow$ COMPUTE
Проход RESOLVE сэмплит глубину сцены и физический атлас в compute-шейдере. Однако стандартный барьер ресурсов в движке переводил текстуру глубины в состояние SHADER_READ строго для FRAGMENT-стадии конвейера. Пришлось вручную прописать:
dstStage = FRAGMENT | COMPUTE
Классическая Vulkan-засада, которую не всегда ловит валидация, а результатом становится внезапный чёрный экран.
6.3. Деревья: из динамики в статический кеш (−3 мс)
Деревья у меня на ветру пока не качаются, то есть геометрически они абсолютно статичны. Но изначально они по ошибке рендерились в динамический атлас каждый божий кадр. Чистая потеря ресурсов. Перевёл их в статический кешируемый атлас с фильтрацией по грязным страницам:
VSMrender (пик при движении): 5.40 мс → 2.41 мс Стоишь на месте: грязных страниц 1–2 из 6144
6.4. Деревья: один Multi-Draw вместо 6288 вызовов (FPS 167 $\rightarrow$ 250)
Профайлинг показал, что кадр внезапно стал CPU-bound (упёрся в процессор) на этапе записи команд отрисовки. Движок генерировал 6288 отдельных вызовов vkCmdDrawIndexedIndirect — по одному на каждое дерево!
Я заменил эту гирлянду на один-единственный вызов vkCmdDrawIndexedIndirect с общим параметром drawCount на всю группу (благо деревья одной группы лежат в indirect-буфере строго друг за другом). Пустые или отсечённые деревья теперь просто имеют instanceCount = 0, что для GPU обходится бесплатно.
CPU-время записи: 6 мс → 4–5 мс Общий FPS: 167 → 200–250 (Самый большой скачок, CPU был стеной)
Важный урок: в старых движках узкое место часто кроется не там, где написана сложная и красивая математика, а в банальной CPU-рутине и записи тысяч драв-коллов.
6.5. Отбраковка теней NPC по дистанции
Изначально сборщик кастеров-скелеток не имел вообще никакого куллинга — он честно обсчитывал скиннинг и бинил абсолютно всех NPC на локации, быстро упираясь в жесткий кап из 256 «листьев». Я добавил базовый тест по дистанции (r_vsm_npc_dist, по умолчанию 50 метров). Учитывая, что в S.T.A.L.K.E.R. в кадр одновременно попадает обычно от 1 до 5 NPC, это дало чистый и стабильный выигрыш.
6.6. Caster-LOD в биннинге (оказалось — мимо)
Ради эксперимента попробовал выбирать более грубый LOD кастера для далёких объектов прямо на этапе работы вычислительного шейдера vsm_bin. Результат оказался маргинальным — прирост в пределах погрешности и шума. Сработал ровно тот же урок, что и с каскадами: ближние кастеры в грязных страницах априори не могут быть «далёкими», а у истинно дальних объектов LOD-0 и так занимает ничтожно мало пикселей. Оставил этот функционал исключительно как скрытую опцию для огромных открытых карт (r_vsm_lod_dist, по умолчанию 0 — выключено).
6.7. Half-Res MARK (−0.5 мс)
Проход разметки страниц (MARK) можно безболезненно гонять в половинном разрешении: один вычислительный поток на блок пикселей $2 \times 2$. Соседние пиксели на экране с огромной долей вероятности всё равно ложатся в пределы одной и той же виртуальной страницы VSM. «Промах» (когда край кадра попал строго между потоками) случается крайне редко, зато общее число потоков и атомарных операций сократилось ровно в четыре раза.
VSMmark: 1.06 мс → 0.53 мс
6.8. Оптимизация копирования неиспользуемых каскадов
Поскольку под VSM больше никто не сэмплит старые собранные карты солнца (всё контролирует экранная маска), я завернул вызовы vkCmdCopyImage для каскадов в условие if (!vsmActive). Это сэкономило ещё около 0.4 мс на пустых операциях копирования в памяти GPU.
Итог большой войны за производительность
A/B тест на одной и той же локации при динамически движущемся солнце:
Метрика кадра | Старый каскад | Новый VSM | Результат |
| sim 6.4мс | sim 6.9 мс | +0.5 мс в худшем сценарии |
| sim 6мс | \sim 5 мс | VSM стал ДЕШЕВЛЕ! |
Пик | 3.3 мс (Тот самый «тик») | Пиков нет | Абсолютная плавность |
В худшем случае нагрузка от VSM составляет всего +0.5 мс, в статике технология работает даже выгоднее каскадов, и самое главное — тени перемещаются идеально плавно там, где кешированный каскад раздражающе дёргался. Разрыв в 6 миллисекунд успешно схлопнулся.
7. «Выглядело как баг VSM, но это был не VSM»
Самое весёлое в графическом программировании — это отладка визуальных артефактов. Вот несколько чистокровных детективных историй, с которыми пришлось столкнуться:
Реконструкция мира через аффинную инверсию. В первой итерации абсолютно все реконструированные точки мира намертво схлопывались в самый верхний уровень клипмапы (L0). Как выяснилось, встроенная функция движка
Fmatrix::invert()выполняет аффинную инверсию матрицы $4 \times 3$. Для проекционной матрицыviewProjона возвращает математический мусор. Пришлось написать честнуюinvert_44. Симптомом-подсказкой послужила гистограмма страниц по уровням: счётчики L1...L5 стояли на нулях, а L0 ломился от переполнения. На этот поиск ушёл не один цикл отладки.Полоски на стыках страниц. Билинейная фильтрация при выборке невольно подтягивала значения глубины из соседних страниц общего текстурного атласа, из-за чего на экране появлялась уродливая сетка швов. Проблема решилась классическим приёмом half-texel inset — принудительным сдвигом координат выборки на половину текселя внутрь текущей страницы.
Краулинг (ползание) тени при вращении солнца. По мере того как солнце медленно поворачивалось на небосводе, вся пиксельная решётка теней на ландшафте начинала «плыть» и мерцать. Баг вылечился привязкой фазы решётки к фиксированной точке мирового пространства рядом с камерой (тот самый проверенный временем трюк из R4 — world-anchored texel snap). Теперь световое пространство честно вращается, но тексельный якорь стоит неподвижно.
Мораль раздела: При работе с кастомными тенями добрая половина времени уходит не на сам алгоритм, а на попытки понять, какой именно из десятка взаимодействующих системных механизмов прямо сейчас даёт эту странную рябь на экране.
8. Что дальше
Сейчас в активной работе находятся две важные архитектурные «конфетки»:
Shadow-HZB (Графический перформанс). Двухфазная occlusion-отбраковка кастеров против иерархического буфера глубины (HZB), построенного на основе атласа из предыдущего кадра. Поскольку у нас тороидальный кеш, прошлый атлас является практически бесплатным источником данных. Цель — полностью победить овердро при растеризации геометрии в «грязные» страницы. Когда это заработает, VSM будет гарантированно обгонять каскад даже в моменты пиковой нагрузки, и технологию можно будет со спокойной совестью выставлять в движке по умолчанию.
SMRT-lite (Визуальное качество). Планирую заменить стандартный фильтр PCF $3 \times 3$ на этапе RESOLVE полноценной трассировкой лучей по карте теней (аналог технологии SMRT из Unreal Engine 5, но адаптированный под старое железо без выделенных RT-ядер — через обычный марш по глубине атласа). Это позволит получить честные мягкие контактные тени (contact-hardened shadows), которые остаются бритвенно-чёткими у основания объектов и реалистично размываются по мере удаления. Настоящая AAA-картинка на видеокартах уровня DirectX 11.
Заключение
Всё началось с простого и наивного желания — сделать так, чтобы солнце в любимой игре красиво и плавно садилось за горизонт. Это желание провело меня через тупик с кешированием каскадов прямо к архитектуре Virtual Shadow Maps и подарило чёткое понимание того, почему инженеры из Epic Games в своё время пришли именно к этому решению.
Главный вывод здесь не столько технический, сколько инженерный:
Когда фича упирается в архитектурный тупик «слишком дорого перерисовывать всё каждый кадр», правильный ответ редко заключается в том, чтобы просто «перерисовывать это реже». Намного эффективнее измельчить гранулярность данных и обновлять только те маленькие кусочки, которые действительно изменились.
А ещё эта история — про настоящую цену осознанно выбранного forward-рендера. На этом пути я честно выплатил все свои «налоги» (необходимость сэмплить тень внутри шести разных шейдеров, костыли с рендерингом оружия в руках, наивный просчёт 16 ламп на пиксель). Но взамен я получил и свои бонусы (готовый depth prepass, общую лёгкость конвейера, гибкость и идеальную дружбу со сглаживанием).
Самое крутое, что каждый forward-налог в итоге закрывается красивым инженерным решением, а не позорной капитуляцией обратно в deferred: по свету ответил Clustered Forward+, по теням — проход RESOLVE (вернувший forward-рендерингу deferred-удобство) в связке с VSM.
Forward — это не «бесплатно и проще». Forward — это «дороже на этапе проектирования, но абсолютно управляемо, если не лениться головой». В итоге связка VSM и Forward+ даёт старому движку то, чего обычный каскад не способен дать в принципе: солнце, которое движется безупречно плавно, и обилие динамических источников света на сцене, которые иначе были бы просто невозможны.
Ну и ниже прикрепляю немного скриншотов того, как рендер выглядит в игре прямо сейчас. А про объемный туман, Ambient Occlusion и Parallax Occlusion Mapping (POM) я подробно расскажу как-нибудь в следующий раз.







видосы буду открыто загружать сюда https://boosty.to/babaiiia как и ссылки
PSS на данный момент производительность кратно больше или равна класическу р4 при условивии запуска того с похожими настройками без урезания, но есть еще огромный кусок куда можно оптимизировать а так же никто не отменял DLSS, FSR, FG итд