Шел уже ХХ-й месяц, как я без работы все еще ковыряю свой 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, который я создал себе сам.

После долгого и активного гугления «в лоб» напрашивались два выхода:

  1. Переписать всё в deferred. Похоронить весь forward-эксперимент и городить G-буфер. Не хотелось — это означало бы отказ от всего, ради чего я в forward и шёл.

  2. Сделать 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. Базовая идея выглядит так:

  1. Гигантское виртуальное разрешение. Представляем виртуальную карту теней настолько огромной и детальной, что на экране каждый пиксель тени получает примерно один тексель карты. В моей реализации — это клипмапа (clipmap) из 6 уровней с виртуальным разрешением $4096 \times 4096$ на каждый уровень.

  2. Разбиение на страницы. Эту огромную виртуальную карту мы нарезаем на небольшие страницы (pages) размером $128 \times 128$ текселей. Всего получается 6144 виртуальные страницы.

  3. Выделение по требованию (Sparse Allocation). Физической памяти под всю эту гигантскую карту в видеопамяти нет, да она и не нужна. Каждый кадр мы анализируем экран и спрашиваем: «Какие конкретно страницы сейчас видны хотя бы одному пикселю на экране?» Физическая память выделяется строго под них.

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

Что это даёт для движущегося солнца? Солнце слегка сдвинулось $\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 страниц по ширине. Решение получилось дешёвым, быстрым и надёжным.

  • Разделение атласа на статику и динамику. Физический атлас жестко поделен на две независимые зоны:

    1. Статический атлас (неподвижная геометрия уровней + статические деревья) — агрессивно кешируется и почти не нагружает систему.

    2. Динамический атлас (скелетная анимация 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

Результат

gpu_total (в движении)

sim 6.4мс

sim 6.9 мс

+0.5 мс в худшем сценарии

gpu_total (стоишь на месте)

sim 6мс

\sim 5 мс

VSM стал ДЕШЕВЛЕ!

Пик Casc0 при инвалидации

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. Что дальше

Сейчас в активной работе находятся две важные архитектурные «конфетки»:

  1. Shadow-HZB (Графический перформанс). Двухфазная occlusion-отбраковка кастеров против иерархического буфера глубины (HZB), построенного на основе атласа из предыдущего кадра. Поскольку у нас тороидальный кеш, прошлый атлас является практически бесплатным источником данных. Цель — полностью победить овердро при растеризации геометрии в «грязные» страницы. Когда это заработает, VSM будет гарантированно обгонять каскад даже в моменты пиковой нагрузки, и технологию можно будет со спокойной совестью выставлять в движке по умолчанию.

  2. 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 итд