Armored Warfare: Проект Армата. Хроматическая аберрация



    Armored Warfare: Проект Армата — бесплатный танковый онлайн-экшн, разрабатываемый Allods Team, игровой студией MY.GAMES. Несмотря на то, что игра сделана на CryEngine, достаточно популярном движке с неплохим realtime render’ом, для нашей игры приходится многое дорабатывать и создавать с нуля. В этой статье я хочу рассказать о том, как мы реализовывали хроматическую аберрацию для вида от первого лица, и что это такое.

    Что такое хроматическая аберрация?


    Хроматическая аберрация – это дефект линзы, при котором в одну и ту же точку приходят не все цвета. Это связано с тем, что показатель преломления среды зависит от длины волны света (см. дисперсия). Вот так, например, выглядит ситуация, когда линза не болеет хроматической аберрацией:


    А вот уже линза с дефектом:


    Кстати, ситуация выше называется продольной (или осевой) хроматической аберрацией. Возникает она в случае, когда различные длины волн не сходятся в одной и той же точке фокальной плоскости после прохождения через линзу. Тогда дефект виден по всей картинке:


    На картинке выше можно увидеть, что из-за дефекта выделяются фиолетовый и зелёный цвета. Не видно? А на этой картинке?


    Также существует боковая (или поперечная) хроматическая аберрация. Возникает она в случае, когда свет падает под углом к линзе. По итогу, различные длины волн света сходятся в разных точках фокальной плоскости. Вот вам картинка для понимания:


    Можно уже по схеме увидеть, что в итоге мы получаем полноценное разложение света от красного до фиолетового. В отличии от продольной, боковая хроматическая аберрация никогда не проявляется в центре, только ближе к краям изображения. Чтобы вы понимали, о чём я, вот вам ещё одна картинка из интернета:


    Ну, раз с теорией мы закончили, давайте переходить к сути.

    Боковая хроматическая аберрация с учётом разложения света


    Начну я всё же с того, что отвечу на вопрос, который мог возникнуть в голове у многих из вас: «а разве в CryEngine нет реализованной хроматической аберрации?» Есть. Но применяется она на этапе пост-процессинга в одном шейдере с sharpening, а алгоритм выглядит так (ссылка на код):

    screenColor.r = shScreenTex.SampleLevel( shPointClampSampler, (IN.baseTC.xy - 0.5) * (1 + 2 * psParams[0].x * CV_ScreenSize.zw) + 0.5, 0.0f).r;
    screenColor.b = shScreenTex.SampleLevel( shPointClampSampler, (IN.baseTC.xy - 0.5) * (1 - 2 * psParams[0].x * CV_ScreenSize.zw) + 0.5, 0.0f).b;

    Что, в принципе, работает. Но у нас игра про танки. Нам этот эффект нужен только для вида от первого лица, и только для красоты, то есть чтобы в центре всё было в фокусе (привет боковой аберрации). Поэтому текущая реализация не устраивала как минимум тем, что её эффект был виден по всей картинке.

    Так выглядела сама аберрация (внимание на левую сторону):


    А так она выглядела, если перекрутить параметры:


    Поэтому, своей целью мы поставили:

    1. Реализовать боковую хроматическую аберрацию, чтобы возле прицела всё было в фокусе, а по бокам если не видно характерных цветных дефектов, то хотя бы чтобы было размыто.
    2. Сэмплировать текстуру, умножая RGB-каналы на коэффициенты, соответствующие конкретной длине волны. Об этом я ещё не рассказывал, поэтому сейчас может быть не совсем понятно, о чём этот пункт. Но мы его обязательно рассмотрим во всех подробностях позже.

    Для начала рассмотрим общий механизм и код для создания боковой хроматической аберрации.

    half distanceStrength = pow(length(IN.baseTC - 0.5), falloff);
    half2 direction = normalize(IN.baseTC.xy - 0.5);
    half2 velocity = direction * blur * distanceStrength;
    

    Итак, сначала строится круговая маска, отвечающая за дистанцию от центра экрана, потом считается направление от центра экрана, а далее это всё перемножается с blur. Blur и falloff – это параметры, которые передаются извне и являются просто множителями для настройки аберрации. Также извне прокидывается параметр sampleCount, который отвечает не только за количество сэмплов, но и, по сути, за шаг между точками сэмплирования, так как

    half2 offsetDecrement = velocity * stepMultiplier / half(sampleCount);
    

    Теперь нам осталось просто проходиться sampleCount раз от данной точки текстуры, сдвигаясь каждый раз на offsetDecrement, домножать каналы на соответствующие веса волн и делить на сумму этих весов. Что ж, пришла пора поговорить о втором пункте нашей глобальной цели.

    Видимый спектр света лежит в диапазоне длин волн от 380 нм (фиолетовый) до 780 нм (красный). И, о чудо, длину волны можно конвертировать в RGB-палитру. На Python код, который занимается этой магией, выглядит так:

    def get_color(waveLength):
        if waveLength >= 380 and waveLength < 440:
            red = -(waveLength - 440.0) / (440.0 - 380.0)
            green = 0.0
            blue  = 1.0
        elif waveLength >= 440 and waveLength < 490:
            red   = 0.0
            green = (waveLength - 440.0) / (490.0 - 440.0)
            blue  = 1.0
        elif waveLength >= 490 and waveLength < 510:
            red   = 0.0
            green = 1.0
            blue  = -(waveLength - 510.0) / (510.0 - 490.0)
        elif waveLength >= 510 and waveLength < 580:
            red   = (waveLength - 510.0) / (580.0 - 510.0)
            green = 1.0
            blue  = 0.0
        elif waveLength >= 580 and waveLength < 645:
            red   = 1.0
            green = -(waveLength - 645.0) / (645.0 - 580.0)
            blue  = 0.0
        elif waveLength >= 645 and waveLength < 781:
            red   = 1.0
            green = 0.0
            blue  = 0.0
        else:
            red   = 0.0
            green = 0.0
            blue  = 0.0
        
        factor = 0.0
        if waveLength >= 380 and waveLength < 420:
            factor = 0.3 + 0.7*(waveLength - 380.0) / (420.0 - 380.0)
        elif waveLength >= 420 and waveLength < 701:
            factor = 1.0
        elif waveLength >= 701 and waveLength < 781:
            factor = 0.3 + 0.7*(780.0 - waveLength) / (780.0 - 700.0)
     
        gamma = 0.80
        R = (red   * factor)**gamma if red > 0 else 0
        G = (green * factor)**gamma if green > 0 else 0
        B = (blue  * factor)**gamma if blue > 0 else 0
        
        return R, G, B
    

    В итоге мы получаем следующее распределение цвета:


    Если коротко, то на графике показано сколько и какого цвета содержится в волне с конкретной длиной. По оси ординат у нас как раз получаются те самые веса, о которых я говорил ранее. Теперь мы можем реализовать полностью алгоритм, с учётом оговоренного ранее:

    half3 accumulator = (half3) 0;
    half2 offset = (half2) 0;
    half3 WeightSum = (half3) 0;
    half3 Weight = (half3) 0;
    half3 color;
    half waveLength;
     
    for (int i = 0; i < sampleCount; i++)
    {
        waveLength = lerp(startWaveLength, endWaveLength, (half)(i) / (sampleCount - 1.0));
        Weight.r = GetRedWeight(waveLength);
        Weight.g = GetGreenWeight(waveLength);
        Weight.b = GetBlueWeight(waveLength);
            
        offset -= offsetDecrement;
            
        color = tex2Dlod(baseMap, half4(IN.baseTC + offset, 0, 0)).rgb;
        accumulator.rgb += color.rgb * Weight.rgb; 
            
        WeightSum.rgb += Weight.rgb;
    }
     
    OUT.Color.rgb = half4(accumulator.rgb / WeightSum.rgb, 1.0);
    

    То есть идея в том, что чем больше у нас sampleCount, тем меньше шаг у нас между точками сэмпла, и тем подробнее дисперсируем свет (учитываем больше волн с различными длинами).

    Если до сих пор непонятно, то давайте рассмотрим конкретный пример, а именно на нашу первую попытку, и я объясню, что брать за startWaveLength и endWaveLength, и как будут реализованы функции GetRed(Green, Blue)Weight.

    Аппроксимация всего диапазона видимого спектра


    Итак, из графика выше мы знаем примерное соотношение и примерные значения RGB палитры для каждой длины волны. Например, для длины волны 380 нм (фиолетовый цвет) (см. тот же график) видим, что RGB(0.4, 0, 0.4). Вот именно эти значения мы и берём за веса, о которых я говорил ранее.

    Попробуем теперь избавиться от функции получения цвета полиномом четвёртой степени, чтобы вычисления были дешевле (мы не студия Pixar, а игровая студия: чем дешевле вычисления, тем лучше). Этот полином четвёртой степени должен аппроксимировать полученные графики. Для построения полинома я воспользовался библиотекой SciPy:

    wave_arange = numpy.arange(380, 780, 0.001)
    red_func = numpy.polynomial.polynomial.Polynomial.fit(wave_arange, red, 4)
    

    В итоге получается следующий результат (я разбил на 3 отдельных графика, соответствующих каждому отдельному каналу, чтобы проще было сравнить с точным значением):




    Для того чтобы значения не выходили за предел отрезка [0, 1], используем функцию saturate. Для красного цвета, например, получается функция:

    half GetRedWeight(half x)
    {
        return saturate(0.8004883122689207 + 
        1.3673160565954385 * (-2.9000047500568042 + 0.005000012500149485 * x) - 
        1.244631137356407 * pow(-2.9000047500568042 + 0.005000012500149485 * x, 2) - 1.6053230172845554 * pow(-2.9000047500568042 + 0.005000012500149485*x, 3)+ 1.055933936470091 * pow(-2.9000047500568042 + 0.005000012500149485*x, 4));
    }
    

    Недостающие параметры startWaveLength и endWaveLength в данном случае являются 780 нм и 380 нм, соответственно. Результат на практике с sampleCount=3 получается следующий (см. края картинки):


    Если же подкрутить значения, увеличить sampleCount до 400, то всё становится лучше:


    К сожалению, у нас realtime render, в котором мы не можем позволить 400 сэмплов (примерно 3-4) в одном шейдере. Поэтому мы немного сократили диапазон длин волн.

    Аппроксимация по части диапазона видимого спектра


    Возьмём такой диапазон, чтобы у нас по итогу были и чисто красный, и чисто синий цвета. От хвостика красного цвета слева тоже отказываемся, так как он очень сильно влияет на итоговый полином. В итоге, получаем распределение на отрезке [440, 670]:


    Также нет необходимости интерполировать по всему отрезку, так как мы теперь можем получить полином только того участка, где значение меняется. Например, для красного цвета, это отрезок [510, 580], где значение веса меняется от 0 до 1. В этом случае можно получить полином второго порядка, который потом функцией saturate также свести к диапазону значений [0, 1]. По всем трём цветам мы получаем следующий результат с учётом сатурации:


    В итоге мы получаем, например, для красного цвета следующий полином:

    half GetRedWeight(half x)
    {
        return saturate(0.5764348105166407 + 
        0.4761860550080825 * (-15.571636738012254 + 0.0285718367412005 * x) - 
        0.06265740390367036 * pow(-15.571636738012254 + 0.0285718367412005 * x, 2));
    }
    

    А на практике с sampleCount=3:


    При этом с перекрученными настройками получается примерно тот же результат, как и при сэмплировании по всему диапазону видимого спектра:


    Таким образом, полиномами второй степени мы получили неплохой результат на диапазоне волн от 440 нм до 670 нм.

    Оптимизация


    Помимо оптимизации расчётов полиномами, можно оптимизировать работу шейдера, опираясь на механизм, который мы заложили в основу нашей боковой хроматической аберрации, а именно, не проводить расчёты в области, где суммарное смещение не выходит за рамки текущего пикселя, иначе мы сэмплируем один и тот же пиксель, и получаем его же.

    Выглядит это примерно так:

    bool isNotAberrated = abs(offsetDecrement.x * g_VS_ScreenSize.x) < 1.0 && abs(offsetDecrement.y * g_VS_ScreenSize.y) < 1.0;
    if (isNotAberrated)
    {
        OUT.Color.rgb = tex2Dlod(baseMap, half4(IN.baseTC, 0, 0)).rgb;
        return OUT;
    }
    

    Оптимизация небольшая, но очень гордая.

    Заключение


    Сама боковая хроматическая аберрация выглядит очень круто, прицелу в центре этот дефект не мешает. Идея с разложением света по весам – очень интересный эксперимент, который может дать совершенно другую картинку, если ваш движок или игра позволяет больше трёх сэмплов. В нашем же случае можно было не заморачиваться и придумать другой алгоритм, так как даже с оптимизациями мы не можем позволить себе много сэмплов, а, например, между 3 и 5 сэмплами разница не очень видна. Вы же можете сами поэкспериментировать с описанным методом и посмотреть на результаты.
    Mail.ru Group
    Building the Internet

    Comments 36

      +1
      Как вы делали тени на земле от танков? Как вы сделали что танк сползает под собственным весом? Как и почему вы сделали что танк противника просвечивается через рельеф в прицел? Похоже на баг
        0
        На тему споттинга танков, возможно, будет отдельная статья
          +1
          Спасибо. Думаю что просто матрица перспективы становится единицей когда юзер переключается на прицел. Ещё интересно как просчитывается баллистика снаряда, летит ли он за конечное время, и где происходит обсчёт снаряда на гпу или цпу и как в этом случае ловятся лаги. Так же есть интересный баг с телепортацией танков, интересны его причины. Так же про тени интересно хотя бы в двух словах. Есть ли у танка собственные тени (от пушки на корпус). Ещё интересно как сделаны следы от гусениц на земле.
        +4
        И всё-таки в статье нет ответа на вопрос — зачем? Крайне странный эффект.
          +1
          Ну вообще как бы реализм, все линзы не идеальны, но в такой реализации этого эфекта просто дофига
            +1
            Есть одна проблема: в RGB потеряна (отсеяна) изначальная информация о спектре, а именно конкретный спектр (а он может сильно отличаться для одного и того же значения RGB) определяет характер искажений, так что полностью достоверной картинки невозможно добиться в принципе
              +1
              В такой достоверности нет необходимости
            +2
            Это все эффекты камеры. Для кинематографичности.
              +2
              Потому что могут. Это как блум в двухтысячных)
              Я сомневаюсь, что сегодня можно найти линзы с такими артефактами. Выглядит так, будто смотришь через бутылку керосина. Но я не танкист, может они так и делают.
                +2
                Такие линзы найти несложно. Их очень много. Как минимум, длиннофокусные объективы, поставляемые в комплекте с фотоаппаратами canon и nikon (я говорю не о дорогих объективах, а о тех, которые идут в комплектах «объектив+тушка») страдают 100% и хроматической аберрацией, и виньеткой. О линзах на танках даже говорить не приходится
                0
                Наверняка здесь есть кто-то кто смотрел в оптику из танка. Не знаю, как это прибор правильно называется. Может быть он дает похожий эффект?
                +1
                Большое спасибо за статью! Выглядит интересно (особенно при сравнении с дефолтом).
                Скажите, а вы отслеживали фидбек людей после изменения своего алгоритма аберраций?
                  0
                  Да, отслеживали. Есть как положительные, так и отрицательные отзывы, хотя эффект мы сделали отключаемым. Так что тем, кому не понравилось, просто могут отключить эффект.
                  Но довольных эффектом осталось больше половины игроков
                  +4
                  Почему люди думают, что это красиво? Все эти моушен блюры, ленс флейры и прочие депф-оф-филды? Мало того, что это уменьшает в два-три раза скорость кадров из-за лишней нагрузки на видеокарту, так еще в итоге картинка мутная. Это же не технодемка или фильм, а игра. Почему разработчики думают, что игрок будет постоянно смотреть на супер-четкий танк в центре экрана, а не рассматривать размытый объект в углу экрана.
                    +4
                    Безотносительно художественной ценности, ни о каких «два-три раза» речи не идет, весь постпроцессинг в играх в худшем случае процентов 20-30 времени кадра занимает. И то, как минимум половина таких проходов нужна обязательно (тонмэппинг, генерация моушн векторов, антиалиасинг, цветокоррекция). Моушн блюр, абберация, виньетка, доф и тому подобные эффекты в сумме может быть процентов 10 времени кадра наберут от силы.

                    А рассматривать внимательно размытый объект в углу скорее всего действительно не будут, если можно камеру навести так, чтобы он в центре был. А в сюжетных играх вообще очень много работы уходит на то, чтобы направлять взгляд игрока туда, куда нужно дизайнеру, и тот же доф может быть вполне в этом полезен.
                      +3
                      Ну может это в первых играх так было, когда ещё только начинали все эти эффекты добавлять. Про дизайнерский эффект соглашусь, но вот когда это делают неотключаемым, например, в гонках или в шутере, а от такого эффекта ещё в глазах это все мельтешит и играть неприятно — вот это действительно странно.
                        0
                        В нашей игре этот эффект можно отключить. Да и настроен он так, что глаза не режет. Вы можете сами в этом убедиться. Игра абсолютна бесплатна и вы можете скачать её на официальном сайте
                          +1
                          Я почему-то думал, что после разлада с Obsidian в 2017 году, про игру постепенно забыли. А так я в неё играл ещё на старте. Только вот сейчас ради неё ставить шпионский агент мейл.ру не особо хочется.
                    +5
                    Как же бесят эти штуки, ха, моушен блур, грип.
                    ХА дает не крутой кинематографичный эффект, а эффект дешевого говна.
                    Эффект выглядит как китайский игрушечный прицел на детской игрушке.
                      +1
                      А вы уверены, что оптимизация с if-ом действительно оптимизация? Я не эксперт, но мне когда-то сказали что в шейдерах if-ы существует не для оптимизаций и что шейдерный блок всё равно будет ждать другие блоки где условие сработало. Замеряли время рендера до и после?
                        +3
                        Блок — это 32/64 близко расположенных пикселя. Шейдер в пределах блока выполняется в локстепе, т.е. каждая инструкция выполняется одновременно для всех элементов блока (варпы в терминологии нвидии, волны у амд). Если на ифе в пределах одного блока идет разделение внутри элементов блока, то часть потоков маскируется, выполняется условие, потом маскировка потоков инвертируется и выполняется else. Так да, выигрыша не будет. Но если весь блок целиком зашел в одну ветку (или не зашел), то ждать ничего не требуется. Плюс если речь идет о сэмплировании текстуры, то там может быть выгодно, даже если не все элементы игнорируют ветку. Причина в том, что разные пиксели читают разные пиксели текстуры. Текстурный блок собирает все запрошенные блоком пиксели в один запрос, и количество запрошенной памяти может быть разным, в зависимости от того, запрошено 32 сэмпла или 1-2.

                        TL;DR: Такое надо замерять, но в случае обращения к текстурам обычно очень выгодно.
                          0
                          Не стоит слепо верить утверждениям вида «А сделано не для Б» по той простой причине, что это утверждение не равносильно «А не годится для Б»
                          +1
                          Боюсь, что повторю мысль многих в комментариях, но эффекта хуже в играх я еще не видел. В первый (и надеюсь в последний) раз увидел его в Bloodborne на PS4 Pro. Признаюсь, сначала был в шоке: думал, может у меня консоль барахлит, или игра криво встала. Нет, так и было задумано.
                          Я обычно стараюсь отключать blur и различные «эффекты камеры» (это при том, что ежедневно почти работаю с видео и им зарабатываю на жизнь), но могу и смириться с их наличием. А вот хроматическая аберрация портила жизнь аж до конца игры, так и не привык. Особенно учитывая отсутствие сглаживания на консоли, тонкие линии вроде железных прутьев забора рассыпались в ужасную разноцветную мешанину.
                          Взгляните сами
                          image
                          … и представьте, что все это добро в 1080P еще растянуто на 4К ТВ.

                          Была бы игра на ПК, наверняка получилось бы отключить этот эффект при большом желании (правкой конфигов, изменением ресурсов игры или чем-то подобным), а вот на консоли такой возможности нет.
                          Кстати, а как у вас с этим дела? Отключается в настройках отдельно, или включено в какой-нибудь из пресетов эффектов?
                            0
                            BB ужасен, это факт. Может портируют на пк по аналогии с другими эксклюзивами. А пока графика будет резать глаза.
                              +1
                              Ох, это не аберрации уже. Это несведение лучей в 3УСЦТ :). Прям в детство вернулся
                                +2
                                Этот эффект у нас можно отключить в настройках. По дефолту он выставлен на высоких и максимальных настройках.
                                А насчёт вашего примера с BB. Обратите внимание, что там этот эффект идёт по всей картинке, то есть там если и аберрация, то продольная. От такой аберрации мы как раз и избавили игру, добавив боковой, и настроив её так, что эффект есть только ближе к краям линзы (и тот едва виден)
                                  –1
                                  Почему вы называете края поля кадра краями линзы?
                                  Спойлер: это (совсем) не одно и то же
                                +2
                                Эффекты портят игры.
                                Особенно блуры и дымки.
                                  +1

                                  Имхо от первого лица это излишний эффект, но если это будет вид из оптического прицела или другой оптики, то почему бы и нет! Но с возможностью отключить.

                                    +1
                                    Интересно. В отличие от комментаторов выше — я не против всей этой движухи с эффектами камеры. Во время няшных динамичных боёв когда всё это в сумме складывается со всем происходящим на хаосом и кавардаком из техники, погодных эфектов, прочей фигни — эти мелочи работают в общую копилку атмосферы.
                                    Игра выглядит конечно значительно хуже конкурентов, но это скорее от общей «пластмассовости» окружения и действующих лиц.
                                    Но геймплейно она отличается во многих нюансах, что делает её самобытной и имеющей право на жизнь.
                                      0
                                      эти мелочи работают в общую копилку атмосферы.

                                      Ага, ведь твой глаз смотрит на в центр экрана, где происходит экшон и этой фигни нет, а только по углам.
                                      +3
                                      Забавно конечно, что производители оптики делают все, чтобы избавиться от ХА, фотографы и видеографы убирают ХА в редакторах, а люди тратят деньги, чтобы повторить этот эффект в игре…
                                        0

                                        Как ёмко выразился на этот счёт один столяр в Ютубе: "мастеру дефект — зрителю эффект".

                                        +2
                                        Вот эта картинка не совсем соответствует физике явления:
                                        image

                                        Будто бы пучки для разных длин волн сходятся в одной плоскости, но в разных точках. Так не бывает.

                                        Вот более корректный вариант (тынц):

                                        image

                                        Красный пучок сходится дальше синего, как и положено, высота синего изображения меньше, чем у красного, что и дает «lateral chromatic aberration», aka «хроматизм увеличения».

                                        На расчеты для игры, впрочем, эта тонкость качественно не влияет.
                                          +1
                                          Да, вы правы, что не совсем соответствует физике. Но для приближённого расчёта, и для наглядности достаточно этих вариантов
                                          А за ваши изображения и за ссылку спасибо!
                                            0
                                            Но для приближённого расчёта

                                            «И так сойдет»
                                            «Главное у нас такое есть»

                                        Only users with full accounts can post comments. Log in, please.