Шел уже ХХ месяц как я без работы все еще ковыряю свой свой Vulkan-рендер для движка X-Ray OGSR когда я начинал делать мод я чуть меньше рабирался в втом как проще) и начитавшись про плюсы forward и так как мне хотелось живого цикла дня и ночи чтоб солнце медленно ползло по небу , а тени так же медленно ползли по стенам начал делать его . Сразу честно, потому что это важно для всей истории. Классический рендер STALKER — это R4, deferred. У него тени уже неплохие и плавные задача нужно сделать не хуже как минимум Но я делал свой рендер с нуля и не сознательно пошёл в forward. Не потому что это «правильнее» — а потому что хотелось наворачивать технологии, экспериментировать, и forward казался более гибким полем для этого.
О том, что именно forward усложнит работу с тенями (и не только с ними), я тогда не до конца думал. В deferred тень от солнца считается один раз — в проходе освещения, по G-буферу. В forward её приходится сэмплить в каждом шейдере геометрии. А у меня их шесть: terrain, lmap, vlit, скелетка (NPC), деревья, трава так что как только я доделал тени я понял что я проигрываю по производительности ощутимо , что делать попробовал отсекать лишнии тени , делать лоды для теней и все равно до произодительности класческого рендера не дотягивал процентов 20% что дальше?
можно было бы конечно сказать включайте апскейлеры которые я доделаю и будет вам счастье но это не путь воина ) попробовал сделать кешировние, и да фпс сразу улетел в х2,5 от класического р4 но неприятный момент всё сломалось: кеш дал тик (тени замирают и скачком перерисовываются при движении солнца). Но тут я понял что вот это то куда нужно копать чтоб сделать круто но как сделать и кеширование и динамику одноврменно на одном экране ? Небольшое отсупление раз уж зашла речь про forward то , разберёмся честно: что это вообще за выбор и почему я о нём не пожалел (хотя поплатился). Сцена сначала рисуется в «толстый» G-буфер: для каждого пикселя экрана сохраняются нормаль, альбедо, глубина, спекуляр и т.д. Потом отдельный проход освещения проходит по этому буферу и считает свет один раз на пиксель. А я сделал именно наивный. В первой версии мой рендер тестировал 16 динамических источников света против КАЖДОГО фрагмента вообще без отбраковки.
Каждый пиксель честно прогонял 16 проверок дальности и 16 затуханий — хотя реально на него влияли 1–3 лампы. В deferred такой проблемы нет by design: там свет считается один раз. Вот он, структурный разрыв с R4, который я создал себе сам. После долгого активного гугления в лоб напрашивались два выхода: 1. Переписать в deferred.
Похоронить весь forward-эксперимент, городить G-буфер. Не хотелось — это отказ от всего, ради чего я в forward и шёл. 2. Сделать forward умным. Это и есть Forward+ (clustered/tiled 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 перерисовывает карту теней каждый кадр с честной мировой привязкой решётки (world-anchored texel snap). Солнце сдвинулось на чуть-чуть — не беда, всё равно перерисовываем всё заново, просто с новым углом. Плавно — но дорого. Я портировал изначально ровно этот подход. Два каскада 4096×4096 (25 м и 60 м вокруг камеры) плюс дальняя карта.
Тени получились плавные и чёткие — но профайлер показал цену: Shadow/CascCull (отбраковка) = 0.01 мс ← почти бесплатно! Ближний каскад в одиночку стоил ~3.1 мс — это был самый дорогой проход во всём кадре, дороже, чем отрисовка всей основной сцены (~2.5 мс). И ключевой момент: отбраковка (culling) — бесплатна (0.01 мс). Значит, дело не в том, что мы рисуем «лишние» объекты.
Дело в самой растеризации: каждый кадр мы заново прогоняем всю геометрию через GPU, чтобы получить ту же самую карту глубины — хотя 99% сцены за этот кадр вообще не изменилось. Вот это «перерисовываем одно и то же каждый кадр» — и есть главная боль. Из неё растёт всё остальное. Логика очевидная: если сцена статична и солнце не движется — зачем перерисовывать тень? Давайте отрендерим её один раз и будем переиспользовать, а перерисовывать только когда что-то реально поменялось. Я так и сделал — добавил кеш каскада (r_shadow_casc_cache): карта теней замораживается и перерисовывается только при достаточном смещении камеры или солнца. Для статичной сцены это сработало великолепно.
Стоишь на месте — стоимость каскада падает почти в ноль. Но как только солнце начинает двигаться, всплывает проблема, которую кешем не решить: - Порог инвалидации стоит, например, на 0.05° смещения солнца. - Солнце ползёт. Накапливается 0.05° — кеш сбрасывается — вся карта 4096² перерисовывается одним кадром. - Глаз видит: тень замерла → дёрнулась → замерла → дёрнулась.
Вот тут стоит остановиться и признать иронию: смоотность, которая в R4 была из коробки, я сломал собственными руками — ровно в тот момент, когда полез её оптимизировать. R4 платил полную цену каждый кадр и был плавным; я попытался не платить — и получил тик. Это и есть тик. И тут — главное прозрение всей истории: > Кеш «всё или ничего» не может дать плавное движущееся солнце. Плавность принципиально требует перепроецирования каждый кадр. А значит, надо не выбирать между «перерисовать всё» и «не перерисовывать», а перерисовывать только то, что реально изменилось и реально видно — мелкими кусочками, каждый кадр.
Можно понизить порог инвалидации до микроскопического — но тогда мы перерисовываем 4096² почти каждый кадр и возвращаемся к исходной цене. Тупик. Проблема не в кешировании. Проблема в гранулярности. Каскад — это монолит. А нужен механизм, который работает на уровне маленьких кусочков карты теней. Именно это и делает 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. Моя цель скромнее и конкретнее: плавное живое солнце по цене каскада на слабом железе. Большинство материалов про VSM — про deferred-рендер (UE5 — deferred). У меня forward, да ещё и в движке 2007 года. Перенос был нетривиальным. Пайплайн VSM за кадр у меня выглядит так:
Compute-проход по буферу глубины: для каждого видимого пикселя реконструирую мировую позицию → определяю уровень клипмапы и страницу → atomicOr в битмаску «нужных» страниц. Off-screen и за спиной игрока страницы-приёмники не отмечаются вообще — это бесплатный выигрыш против каскада, который рисует полный ортобокс на 360° вокруг камеры независимо от того, куда смотришь. MARK'у нужен буфер глубины сцены — и он у меня уже был. В forward-рендере я и так делаю depth prepass (отдельный ранний проход, рисующий только глубину), чтобы early-Z убивал переотрисовку в дорогом цветовом проходе.
Этот prepass я добавлял по чисто forward-причине — борьба с овердро освещения. А VSM достался он бесплатно: тот же буфер глубины, который спасает forward от овердро, оказался ровно тем, что нужно для разметки страниц. Редкий случай, когда forward-налог окупился сам собой. Один поток на виртуальную страницу: если страница нужна — атомарно резервируем физический слот в атласе, пишем pageTable[virtual] → slot.
Для каждого кастера (объекта-тенеотбрасывателя) ищем, в какие резидентные страницы попадает его проекция в свете солнца, и формируем списки + indirect-команды отрисовки. Это и есть «наш Nanite» на compute. Растеризуем геометрию только в те страницы, что изменились (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 вокруг растеризации солнца: Минус 2.7 мс просто за то, что перестал делать двойную работу. > Forward-налог: оружие в руках. Один читатель карт солнца всё-таки остался — модель оружия в руках (HUD weapon).
И это чисто forward-специфичная заноза. В forward оружие рендерится тем же пайплайном, что и мир, но в своей искажённой проекции (его «мировые» координаты — фейковые, чтобы ствол не упирался в стены и красиво держался у камеры). Из-за этого screen-space VSM-маска для него считалась бы неправильно — её мировая реконструкция к фейковым координатам оружия не применима. Поэтому оружие я оставил на старом каскадном сэмплинге, а под VSM оно получает слегка устаревший «замороженный» каскад — на глаз незаметно.
В deferred такого спецслучая, скорее всего, не возникло бы: там тень читается из общего экранного результата, а не пересчитывается из координат меша. Мелочь, но показательная — forward постоянно подсовывает такие частные случаи там, где deferred обходится одним общим путём. Resolve сэмплит глубину сцены и атлас в compute, а стандартный барьер движка переводил depth в SHADER_READ только для fragment-стадии. Пришлось руками прописать dstStage = FRAGMENT | COMPUTE.
Классическая Vulkan-засада, которую находишь только через валидационные слои и чёрный экран. ### 6.3. Деревья: из динамики в статический кеш (−3 мс) Деревья у меня не качаются на ветру (пока), то есть геометрически статичны — но рендерились в динамический атлас каждый кадр. Чистая потеря. Перевёл их в статический кешируемый атлас с фильтром по грязным страницам: ### 6.4. Деревья: один multi-draw на группу вместо 6288 вызовов (fps 167 → 250)
Оказалось, кадр был CPU-bound на записи команд отрисовки: 6288 отдельных vkCmdDrawIndexedIndirect, по одному на дерево. Заменил на один vkCmdDrawIndexedIndirect с drawCount на всю группу (деревья группы лежат подряд в indirect-буфере). Пустые деревья просто имеют instanceCount = 0 — на GPU бесплатно. FPS: 167 → 200–250 ← самый большой скачок, CPU был стеной Урок: на старом движке узкое место часто не там, где красивая математика, а в банальной записи тысяч драв-коллов.
Сборщик кастеров-скелеток не имел вообще никакого culling — скинил и бинил всех NPC независимо от расстояния, упираясь в кап в 256 «листьев». Добавил тест по дистанции (r_vsm_npc_dist, по умолчанию 50 м). В STALKER в кадре обычно 1–5 NPC, так что это почти всегда чистый выигрыш. Попробовал выбирать грубый LOD кастера для далёких объектов прямо в vsm_bin.
Маргинально — в пределах шума. Ровно тот же урок, что и с каскадом: ближние кастеры в грязных страницах не «далёкие», а у дальних LOD-0 уже мелкий. Оставил как опцию для открытых карт (r_vsm_lod_dist, по умолчанию 0 = выкл). Проход MARK можно гонять в полразрешения: один поток на блок 2×2.
Соседние пиксели всё равно ложатся в одну страницу, так что «промах» 1 из 4 почти не случается, а потоков и атомиков — вчетверо меньше. Под VSM никто не сэмплит собранные карты солнца (маска рулит всем). Завернул vkCmdCopyImage каскадов в if (!vsmActive) — ещё ~0.4 мс. Casc0 на redraw ПИК 3.3 мс нет пика → это и есть «тик»
VSM стоит +0.5 мс в худшем случае, дешевле в спокойном кадре, и главное — он плавный там, где кешированый каскад дёргается. Разрыв в 6 мс схлопнулся в полмиллисекунды. [GIF: профайлер — каскад спайкает Casc0, VSM ровная линия] Самое весёлое в графике — это артефакты. Несколько детективных историй. Реконструкция мира через аффинную инверсию.
Все реконструированные точки схлопывались в уровень L0 клипмапы. Причина: в X-Ray Fmatrix::invert() — это аффинная инверсия 4×3, она даёт мусор для проективной viewProj. Симптом-подсказка: гистограмма страниц по уровням — L1..L5 все по нулям, всё в L0. (Стоило цикла отладки.) Полоски на стыках страниц. Билинейная фильтрация подтягивала глубину из соседней страницы атласа → сетка швов.
Лечится half-texel inset (вставкой на полтекселя внутрь страницы). Краулинг тени при вращении солнца. Решётка теней «ползла», пока солнце поворачивалось. Лечится мировой привязкой фазы решётки к точке мира рядом с камерой (R4-трюк world-anchored texel snap) — световое пространство вращается, а тексель якоря стоит на месте. Мораль раздела: при работе с тенями половина времени уходит не на алгоритм, а на то, чтобы понять, какой именно из десятка взаимодействующих механизмов даёт рябь. 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+ рендере старого движка то, чего каскад не даёт в принципе: солнце, которое движется плавно и множество истоничнок света в сцене которые подругому невоможны , ну и немного скриншотов того как сейчс выглядит рендер , про туман ао и пом расскау может потом
видосы буду открыто загружать сюда https://boosty.to/babaiiia как и ссылки PSS на данный момент производительность кратно больше или равна класическу р4 при условивии запуска того с похожими настройками без урезания , но есть еще огромный кусок куда можно оптимизировать а так же никто не отменял DLSS , FSR , FG итд