Шел уже ХХ месяц как я без работы все еще ковыряю свой свой Vulkan-рендер для движка X-Ray OGSR когда я начинал делать мод я чуть меньше рабирался в втом как проще) и начитавшись про плюсы forward и так как мне хотелось живого цикла дня и ночи чтоб солнце медленно ползло по небу , а тени так же медленно ползли по стенам начал делать его .
Сразу честно, потому что это важно для всей истории. Классический рендер STALKER — это R4, deferred. У него тени уже неплохие и плавные задача нужно сделать не хуже как минимум
Но я делал свой рендер с нуля и не сознательно пошёл в forward. Не потому что это «правильнее» — а потому что хотелось наворачивать технологии, экспериментировать, и forward казался более гибким полем для этого. О том, что именно forward усложнит работу с тенями (и не только с ними), я тогда не до конца думал.
В deferred тень от солнца считается один раз — в проходе освещения, по G-буферу. В forward её приходится сэмплить в каждом шейдере геометрии. А у меня их шесть: terrain, lmap, vlit, скелетка (NPC), деревья, трава так что как только я доделал тени я понял что я проигрываю по производительности ощутимо , что делать попробовал отсекать лишнии тени , делать лоды для теней и все равно до произодительности класческого рендера не дотягивал процентов 20% что дальше? можно было бы конечно сказать включайте апскейлеры которые я доделаю и будет вам счастье но это не путь воина ) попробовал сделать кешировние, и да фпс сразу улетел в х2,5 от класического р4 но неприятный момент всё сломалось: кеш дал тик (тени замирают и скачком перерисовываются при движении солнца).
В оригинальном STALKER цикл времени суток есть, Звучит как мелочь. На деле — это одна из самых дорогих вещей в реалтайм-графике, и вот почему.
Тень от солнца — это направленный источник света на всю сцену. Не лампочка в комнате, а буквально всё, что видит игрок, на километры вперёд. Чтобы построить такую тень, надо отрендерить всю геометрию ещё раз — глазами солнца — в карту глубины (shadow map). Каждый кадр.Но тут я понял что вот это то куда нужно копать чтоб сделать круто но как сделать и кеширование и динамику одноврменно на одном экране ?

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

А я сделал именно наивный. В первой версии мой рендер тестировал 16 динамических источников света против КАЖДОГО фрагмента вообще без отбраковки. Каждый пиксель честно прогонял 16 проверок дальности и 16 затуханий — хотя реально на него влияли 1–3 лампы. В deferred такой проблемы нет by design: там свет считается один раз. Вот он, структурный разрыв с R4, который я создал себе сам.
После долгого активного гугления в лоб напрашивались два выхода:
1. Переписать в deferred. Похоронить весь forward-эксперимент, городить G-буфер. Не хотелось — это отказ от всего, ради чего я в forward и шёл.
2. Сделать forward умным. Это и есть Forward+ (clustered/tiled forward).
### Что такое Forward+ и что я для этого сделал
Идея Forward+: не проверять каждый пиксель против всех ламп, а заранее разложить источники света по ячейкам экрана (compute-проходом), чтобы каждый пиксель перебирал только те лампы, что реально достают до его ячейки. Это не уменьшение числа ламп — наоборот: становится дёшево поднять лимит и зажечь хоть сотни источников.
3D-сетка froxel’ов 16×9×24 = 3456 ячеек, разрешение-независимая (тайлы масштабируются). Срезы по глубине — экспоненциальные (схема Olsson/Doom: slice = log2(zview)*scale + bias), чтобы детализация ячеек шла по логарифму глубины, как и положено.
Один compute-диспатч, без атомиков. Один поток на froxel: строит свой AABB во view-пространстве, тестирует сферу каждого источника на пересечение (sphere-vs-AABB) и пишет индексы попавших ламп в свой фиксированный участок буфера ([ci*64 .. ci*64+64)) — участки не пересекаются, поэтому атомики не нужны.
Фрагмент вычисляет свою ячейку из gl_FragCoord + view-глубины и перебирает только её список. Лимит источников поднят с 16 до 256.
Отладочный хитмап (r_clustered_debug) — раскраска по числу ламп в ячейке: костёр = 1 синяя ячейка, гроздь ламп = красно-жёлтые, фонарик = синяя сфера вокруг игрока. Очень помогает убедиться, что биннинг корректен, до того как доверять картинке.
Корректность. Кластерный путь — это надмножество правильных ламп (свободный AABB даёт пару ложных попаданий, их добивает затухание). Поэтому при ≤16 лампах картинка обязана быть идентична старому пути, просто быстрее. Так и вышло: A/B в игре показал, что r_clustered 1 и r_clustered 0 неотличимы — фонарик, костры, лампы светят одинаково.
Урок, который я вынес: forward — не «устаревший» выбор, а осознанный, как у Doom и UE5. Но он живёт только пока ты не наивен. Наивный forward проигрывает deferred структурно; Forward+ закрывает этот разрыв без переписывания в deferred. А раз так — ставка на forward оправдана, и можно навешивать на него всё остальное, включая VSM.
—
2. Почему каскады — это дорого (даже когда они плавные)
Стандартное решение для солнца — Cascaded Shadow Maps (CSM). Идея простая: близко к камере нужна высокая детализация тени, далеко — низкая. Поэтому делают несколько «каскадов» — вложенных областей разного размера, и в каждую рендерят свою карту теней. Именно так работает и R4.
И вот тут — важный момент, который я сначала недооценил. Каскады R4 плавные. Но плавные они не магией, а грубой силой: R4 перерисовывает карту теней каждый кадр с честной мировой привязкой решётки (world-anchored texel snap). Солнце сдвинулось на чуть-чуть — не беда, всё равно перерисовываем всё заново, просто с новым углом. Плавно — но дорого.
Я портировал изначально ровно этот подход. Два каскада 4096×4096 (25 м и 60 м вокруг камеры) плюс дальняя карта. Тени получились плавные и чёткие — но профайлер показал цену:
«`
Shadow/Casc0 (ближний 4096²) = 3.0–3.15 мс
Shadow/Casc1 (2048²) ~ 0–1.1 мс
Shadow/CascCull (отбраковка) = 0.01 мс ← почти бесплатно!
«`
Ближний каскад в одиночку стоил ~3.1 мс — это был самый дорогой проход во всём кадре, дороже, чем отрисовка всей основной сцены (~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 для Unreal Engine 5. Базовая идея:
1. Представляем гигантскую виртуальную карту теней — настолько детальную, что на экране каждый пиксель тени получает примерно один тексель карты. У меня это клипмапа из 6 уровней, виртуальное разрешение 4096² на уровень.
2. Эту виртуальную карту режем на страницы (pages) 128×128 текселей. Всего получается 6144 страницы.
3. Физической памяти под всю виртуальную карту нет и не надо. Каждый кадр мы спрашиваем: какие страницы вообще видны хоть одному пикселю на экране? Только под них выделяем реальную память.
4. Рендерим тень только в нужные страницы. И — главное для нашей задачи — кешируем их в мировом пространстве: страница, привязанная к точке мира, остаётся валидной между кадрами, пока её содержимое не изменилось.
Что это даёт для движущегося солнца? Солнце сдвинулось → инвалидируется и перерисовывается не вся карта, а только видимые страницы, причём размазанно во времени (round-robin: ~1/N страниц за кадр). Стоишь, солнце замерло → перерисовывается ноль страниц. Плавно И дёшево.
> Важная честность. В UE5 VSM создавался под Nanite — там геометрия рендерится прямо в страницы почти бесплатно. У меня нет ни Nanite, ни mesh-шейдеров (целевая аудитория STALKER — слабое железо, а mesh-шейдеры это NVIDIA Turing+/AMD RDNA2+). Поэтому путь рендера в страницы у меня — на compute-биннинге, и это, по меркам Epic, их официальный медленный путь. Я не пытаюсь обогнать UE5+Nanite. Моя цель скромнее и конкретнее: плавное живое солнце по цене каскада на слабом железе.
—
5. Как это легло на forward-рендер X-Ray
Большинство материалов про VSM — про deferred-рендер (UE5 — deferred). У меня forward, да ещё и в движке 2007 года. Перенос был нетривиальным. Пайплайн VSM за кадр у меня выглядит так:
MARK — какие страницы видны.
Compute-проход по буферу глубины: для каждого видимого пикселя реконструирую мировую позицию → определяю уровень клипмапы и страницу → atomicOr в битмаску «нужных» страниц. Off-screen и за спиной игрока страницы-приёмники не отмечаются вообще — это бесплатный выигрыш против каскада, который рисует полный ортобокс на 360° вокруг камеры независимо от того, куда смотришь.
Forward-бонус. MARK’у нужен буфер глубины сцены — и он у меня уже был. В forward-рендере я и так делаю depth prepass (отдельный ранний проход, рисующий только глубину), чтобы early-Z убивал переотрисовку в дорогом цветовом проходе. Этот prepass я добавлял по чисто forward-причине — борьба с овердро освещения. А VSM достался он бесплатно: тот же буфер глубины, который спасает forward от овердро, оказался ровно тем, что нужно для разметки страниц. Редкий случай, когда forward-налог окупился сам собой.
ALLOC — выделить физику.
Один поток на виртуальную страницу: если страница нужна — атомарно резервируем физический слот в атласе, пишем pageTable[virtual] → slot.
BIN — кто отбрасывает тень в какую страницу.
Для каждого кастера (объекта-тенеотбрасывателя) ищем, в какие резидентные страницы попадает его проекция в свете солнца, и формируем списки + indirect-команды отрисовки. Это и есть «наш Nanite» на compute.
RENDER — рисуем только грязные страницы.
Растеризуем геометрию только в те страницы, что изменились (loadOp = LOAD, очистка только грязных). Кешированные страницы не трогаем.
RESOLVE — собираем экранную маску.
Тут архитектурный сдвиг: вместо того чтобы каждый шейдер-приёмник лез в атлас, отдельный compute-проход разрешает тень для всего экрана в screen-space маску (RGBA16F) и применяет к ней temporal-сглаживание. После этого каждый из 6 шейдеров-приёмников (terrain / lmap / vlit / skinned / tree / grass) делает буквально одну строчку: texture(uVsmMask, screenUV).r.
Forward-бонус (тот самый возврат долга). Помните, во вступлении forward отомстил тем, что тень надо сэмплить в шести шейдерах вместо одного deferred-прохода? Вот здесь я долг и вернул. RESOLVE считает тень один раз на весь экран — ровно как deferred-проход освещения по G-буферу. Шесть шейдеров после этого не считают тень, а просто читают готовую маску одной строкой. По сути я воспроизвёл deferred-удобство точечно для теней, не заводя G-буфер.
Бонусом — temporal-сглаживание тут делается без motion vectors, которых у моего forward-рендера нет этого пока что нет ) . Репроекция истории идёт через глубину + матрицу прошлого кадра (prevViewProj), а не через буфер скоростей. (Кстати, ★самый дорогой баг всего VSM был именно тут: «дрожащие шафты» оказались не из-за теней, а из-за того, что temporal репроецировал дрожащий от джиттера мир; лечится репроекцией нежиттеренного центра, а джиттерится только сэмпл.)
Мировая привязка (world-anchored) — фундамент всего кеша. Матрицу вида солнца я строю с «глазом» в начале координат, а не на камере. Тогда XY в световом пространстве не зависит от камеры → страницы привязаны к точкам мира → их можно кешировать между кадрами. Резидентность — тороидальная, вообще без хеш-таблиц и free-list:
Модуло само работает как вытеснение — коллизий нет, пока окно 32 страницы шириной. Дёшево и сердито.
Атлас разделён на статический (статичная геометрия + деревья) и динамический (NPC-скелетка + трава): статика кешируется, динамика перерисовывается. Resolve сэмплит оба и берёт min(глубин) — результат идентичен одному общему атласу.
—
## 6. Война за производительность: с +6 мс до +0.5 мс
Когда VSM впервые заработал «честно» (всё перерисовываем каждый кадр), он был на ~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 → COMPUTE
Resolve сэмплит глубину сцены и атлас в compute, а стандартный барьер движка переводил depth в 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 → 250)
Оказалось, кадр был CPU-bound на записи команд отрисовки: 6288 отдельных vkCmdDrawIndexedIndirect, по одному на дерево. Заменил на один vkCmdDrawIndexedIndirect с drawCount на всю группу (деревья группы лежат подряд в indirect-буфере). Пустые деревья просто имеют instanceCount = 0 — на GPU бесплатно.
«`
CPU: 6 мс → 4–5 мс
FPS: 167 → 200–250 ← самый большой скачок, CPU был стеной
«`
Урок: на старом движке узкое место часто не там, где красивая математика, а в банальной записи тысяч драв-коллов.
### 6.5. Отбраковка теней NPC по дистанции
Сборщик кастеров-скелеток не имел вообще никакого culling — скинил и бинил всех NPC независимо от расстояния, упираясь в кап в 256 «листьев». Добавил тест по дистанции (r_vsm_npc_dist, по умолчанию 50 м). В STALKER в кадре обычно 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×2. Соседние пиксели всё равно ложатся в одну страницу, так что «промах» 1 из 4 почти не случается, а потоков и атомиков — вчетверо меньше.
«`
VSMmark: 1.06 мс → 0.53 мс
«`
### 6.8. Вернуть копии каскада под VSM
Под VSM никто не сэмплит собранные карты солнца (маска рулит всем). Завернул vkCmdCopyImage каскадов в if (!vsmActive) — ещё ~0.4 мс.
Итог войны
A/B на одной локации, движущееся солнце:
«`
Каскад VSM
gpu_total ~6.4 мс ~6.9 мс → +0.5 мс «в нагрузке»
calm (стоишь) ~6 мс ~5 мс → VSM ДЕШЕВЛЕ
Casc0 на redraw ПИК 3.3 мс нет пика → это и есть «тик»
«`
VSM стоит +0.5 мс в худшем случае, дешевле в спокойном кадре, и главное — он плавный там, где кешированый каскад дёргается. Разрыв в 6 мс схлопнулся в полмиллисекунды.
[GIF: профайлер — каскад спайкает Casc0, VSM ровная линия]
7. «Выглядело как баг VSM, но это был не VSM»
Самое весёлое в графике — это артефакты. Несколько детективных историй.
Реконструкция мира через аффинную инверсию. Все реконструированные точки схлопывались в уровень L0 клипмапы. Причина: в X-Ray Fmatrix::invert() — это аффинная инверсия 4×3, она даёт мусор для проективной viewProj. Нужна была invert_44. Симптом-подсказка: гистограмма страниц по уровням — L1..L5 все по нулям, всё в L0. (Стоило цикла отладки.)
Полоски на стыках страниц. Билинейная фильтрация подтягивала глубину из соседней страницы атласа → сетка швов. Лечится half-texel inset (вставкой на полтекселя внутрь страницы).
Краулинг тени при вращении солнца. Решётка теней «ползла», пока солнце поворачивалось. Лечится мировой привязкой фазы решётки к точке мира рядом с камерой (R4-трюк world-anchored texel snap) — световое пространство вращается, а тексель якоря стоит на месте.
Мораль раздела: при работе с тенями половина времени уходит не на алгоритм, а на то, чтобы понять, какой именно из десятка взаимодействующих механизмов даёт рябь.
8. Что дальше
Две «конфетки» в работе:
1. Shadow-HZB (перф-гем). Двухфазная occlusion-отбраковка кастеров против HZB, построенного из атласа прошлого кадра (тороидальный кеш делает его практически бесплатным источником). Цель — добить главную оставшуюся стоимость (overdraw при растеризации в грязные страницы) и сделать так, чтобы VSM обгонял каскад даже в нагрузке — тогда дефолт можно будет честно переключить.
2. SMRT-lite (визуальный гем). Заменить 3×3 PCF в resolve на трассировку лучей по карте теней (как в UE5 SMRT, но без RT-железа — просто марш по глубине атласа): мягкие контактные тени, которые ужесточаются вблизи объекта и размываются вдали. AAA-картинка на DX11-классе железа.
Заключение
Я начал с простого желания — чтобы солнце красиво садилось. Это привело меня через тупик кеширования каскада к Virtual Shadow Maps — и к пониманию, почему Epic вообще это придумали. Главный вывод не технический, а инженерный:
Когда фича упирается в «дорого перерисовывать всё каждый кадр», ответ редко в том, чтобы «перерисовывать реже». Чаще — в том, чтобы измельчить гранулярность и перерисовывать только то, что правда изменилось.
А ещё это вся история — про цену осознанно выбранного forward. Я насчитал по ходу свои налоги (тень надо сэмплить в шести шейдерах, спецслучай оружия в руках, наивный свет в 16 ламп на пиксель) и свои бонусы (готовый depth prepass, лёгкость, гибкость, дружба со сглаживанием). И главное — каждый налог закрывается умным решением, а не капитуляцией в deferred: по свету ответил Forward+, по теням — resolve-маска (вернувшая forward’у deferred-удобство) и VSM. Forward — это не «бесплатно проще», это «дороже, но управляемо, если не лениться».
VSM в forward-рендере старого движка — это не «как в UE5», это его медленный путь, аккуратно подогнанный под слабое железо. Но он даёт то, чего каскад не даёт в принципе: солнце, которое движется плавно,
ну и немного скриншотов того как сейчс выглядит рендер , про туман ао и пом расскау может потом






видосы буду открыто загружать сюда https://boosty.to/babaiiia как и ссылки
ссылка на оригинал статьи https://habr.com/ru/articles/1049338/