company_banner

Пост-эффекты в мобильных играх



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

    Что касается пост-обработки — её волшебное действие на фотографии было открыто задолго до появления первых компьютеров, а её математический и алгоритмический базис, созданный для цифровой обработки изображений, удачно вписался в программируемый конвейер GPU.

    Помимо того, что пост-эффекты (точнее — их не очень грамотное использование) являются предметом ненависти среди игроков, они также едва ли не единственный способ быстро и дешево «оживить» и «освежить» картинку. Насколько качественным получится это «оживление» и не обернется ли оно в результате «свежеванием», зависит по большей части от художников.


    Слегка «освежеванный» скриншот War Robots.

    Как уже было сказано выше, эта статья будет посвящена в основном оптимизации. Для тех кто не в теме — отличным вводным курсом будут книги из серии GPU Gems, первые три из которых доступны на сайте NVidia [1].

    Рассматриваемые примеры реализованы на Unity, тем не менее методы оптимизации, описанные здесь, применимы к любой среде разработки.

    Оптимальная архитектура пост-обработки


    Существует два способа рендеринга пост-эффектов:

    • последовательный — когда рендеринг разбивается на отдельные шаги и на каждом шаге к изображению применяется только один пост-эффект за шаг;
    • пакетный — сначала рендерится промежуточный результат каждого эффекта, а затем на финальном шаге применяются все пост-эффекты.

    Последовательный рендеринг проще в реализации и удобнее с точки зрения конфигурирования. Он элементарно реализуется как список объектов типа «пост-эффект», порядок рендеринга которых в теории произволен (на практике нет), и более того — один и тот же тип эффекта может быть применен несколько раз. На самом деле подобные достоинства востребованы лишь в единичных случаях.

    В то же время пакетный рендеринг заметно эффективнее, поскольку он экономит общее число обращений к памяти. Последнее наиболее актуально для мобильных платформ, на которых повышенная вычислительная нагрузка сопровождается повышенной же теплоотдачей (кто бы мог подумать). И даже если устройство сумеет выдать требуемую частоту кадров, вряд ли игроку будет комфортно играть, держа в руках горячий «кирпич».

    Для наглядности приведу последовательную и пакетную схемы рендеринга пост-эффектов, используемых в War Robots.


    Последовательный рендеринг: 8 чтений, 6 записей.


    Пакетный рендеринг: 7 чтений, 5 записей.

    Пакетный рендеринг для Unity реализован в модуле Post Processing Stack [2].

    Последовательность применения пост-эффектов без изменения кода изменить невозможно (но и не нужно), а вот отдельные пост-эффекты отключить можно. Кроме того, в модуле интенсивно используется встроенный в Unity кэш ресурсов RenderTexture [3], так что в коде конкретного пост-эффекта, как правило, содержатся только инструкции по рендерингу.

    Ресурсы же пост-эффект запрашивает непосредственно во время рендеринга, и освобождает их по его завершению. Это позволяет организовать повторное использование ресурсов в последующих пост-эффектах, поскольку кэш удаляет только те ресурсы, которые не были востребованы в течении последних нескольких кадров.

    Финальный этап в пакетном рендеринге — композиционный эффект, который комбинирует результаты всех предшествующих шагов и рендерит их при помощи мультивариантного «убер-шейдера». В Unity3D такой шейдер можно сделать при помощи директив препроцессора #pragma multi_compile или #pragma shader_feature.

    В целом, Post Processing Stack нам понравился, но все же без доработки напильником дело не обошлось. Нам требовался масштабируемый модуль с возможностью добавлять или заменять пост-эффекты (включая препассы), а также модифицировать захардкоженный пайплайн, задающий последовательность рендеринга, и композиционный «убер-шейдер». Плюс ко всему в эффектах были разнесены настройки качества эффекта и его параметры на конкретной сцене.

    Оптимизация fillrate


    Основной метод рендеринга в пост-процессинге — это блиттинг: заданный шейдер применяется ко всем фрагментам текстуры, используемой в качестве render target. Таким образом, производительность рендеринга зависит от размера текстуры и вычислительной сложности шейдера. Простейший способ повысить производительность (а именно — уменьшение размера текстуры) сказывается на качестве пост-процессинга.

    Но если заранее известно, что рендеринг необходим только в определенной области текстуры, можно оптимизировать процесс, к примеру, заменив блиттинг на рендеринг 3D-модели. Разумеется, никто не запрещает вместо этого использовать настройки viewport'а, но 3D-модель отличается от блиттинга увеличенным объемом per-vertex данных, которые в свою очередь позволяют задействовать более «продвинутые» вертексные шейдеры.

    Именно так мы поступили с пост-эффектом рассеивания света от солнца [4]. Мы упростили оригинальный препасс, заменив его на рендеринг биллбоарда с текстурой «солнца». Фрагменты биллбоарда, скрытые за объектами сцены, выделялись с использованием полноэкранной маски, которая по совместительству служит нам буфером теней (подробнее о рендеринге теней я расскажу чуть позже).


    Справа: буфер теней и маска, которая получается, если применить к нему степ-функцию. Все тексели, альфа которых меньше 1, перекрывают собой “солнце”.

    struct appdata
    {
        float4 vertex : POSITION;
        half4 texcoord : TEXCOORD0;
    }; 
    
    struct v2f
    {
        float4 pos : SV_POSITION;
        half4 screenPos : TEXCOORD0;
        half2 uv : TEXCOORD1;
    };

    #include “Unity.cginc”
    sampler2D _SunTex;
    sampler2D _WWROffscreenBuffer; 
    half4 _SunColor;
    
    v2f vertSunShaftsPrepass(appdata v)
    {
        v2f o;
        o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
        o.screenPos = ComputeScreenPos(o.pos);
        o.uv = v.texcoord;
        return o;
    }

    fixed4 fragSunShaftsPrepass(v2f i) : COLOR
    {
        // Тексели _WWROffscreenBuffer с альфа-компонентом == 1 
        // не спроецированы на геометрию сцены
    
        const half AlphaThreshold = 0.99607843137; // 1 - 1.0/255.0
    
        fixed4 result = tex2D( _SunTex, i.uv ) * _SunColor;
        half shadowSample = tex2Dproj( 
            _WWROffscreenBuffer, 
            UNITY_PROJ_COORD(i.screenPos) 
        ).a;
        return result * step( AlphaThreshold, shadowSample );
    }

    Сглаживание текстуры препасса также выполняется при помощи рендеринга 3D-модели.



    struct appdata
    {
        float4 vertex : POSITION;
    }; 
    
    struct v2f
    {
        float4 pos : SV_POSITION;
        half4 screenPos : TEXCOORD0;
    };

    #include “Unity.cginc”
    
    sampler2D _PrePassTex;
    half4 _PrePassTex_TexelSize; 
    half4 _BlurDirection;
    
    v2f vertSunShaftsBlurPrepass(appdata v)
    {
        v2f o;
        o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
        o.screenPos = ComputeScreenPos(o.pos);
        o.uv = v.texcoord;
        return o;
    }

    fixed4 fragSunShaftsBlurPrepass(v2f i) : COLOR
    {
        half2 uv = i.screenPos.xy / i.screenPos.w;
        half2 blurOffset1 = _BlurDirection * _PrePassTex_TexelSize.xy * 0.53805;
        half2 blurOffset2 = _BlurDirection * _PrePassTex_TexelSize.xy * 2.06278;
        half2 uv0 = uv + blurOffset1;
        half2 uv1 = uv – blurOffset1;
        half2 uv2 = uv + blurOffset2;
        half2 uv3 = uv – blurOffset2;
        return (tex2D(_PrePassTex, uv0) + tex2D(_PrePassTex, uv1)) * 0.44908 +
               (tex2D(_PrePassTex, uv2) + tex2D(_PrePassTex, uv3)) * 0.05092;
    }

    Разумеется, мы пошли до конца: финальный проход тоже сделан с помощью рендеринга 3D-модели. И в отличие от предыдущих случаев, которые при желании можно заменить блиттингом во вьюпорт, здесь 3D-модель содержит дополнительные данные (цвет вертекса), которые используются в шейдере эффекта.



    struct appdata
    {
        float4 vertex : POSITION;
        float4 color : COLOR;
    }; 
    
    struct v2f
    {
        float4 pos : POSITION;
        float4 color : COLOR;
        float4 screenPos : TEXCOORD0;
    };

    #include “Unity.cginc”
    
    sampler2D _PrePassTex;
    float4 _SunScreenPos; 
    int _NumSamples;
    int _NumSteps;
    float _Density;
    float _Weight;
    float _Decay;
    float _Exposure;
    
    v2f vertSunShaftsRadialBlur(appdata v)
    {
        v2f o;
        o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
        o.screenPos = ComputeScreenPos(o.pos);
        o.color = v.color;
        return o;
    }

    float4 fragSunShaftsRadialBlur(v2f i) : COLOR
    {
        float4 color = i.color;
        float2 uv = i.screenPos.xy / i.screenPos.w;
        float2 deltaTextCoords = (uv - _SunScreenPos.xy) / float(_NumSamples) * _Density;
        float2 illuminationDecay = 1.0;
        float4 result = 0; 
        float4 sample0 = tex2D(_PrePassTex, uv);
        for(int i=0; i<_NumSteps; i++)
        {
            uv -= deltaTextCoords * 2;
            float4 sample2 = tex2D(_PrePassTex, uv);
            float4 sample1 = (sample0 + sample2) * 0.5;
    
            result += sample0 * illuminationDecay * _Weight;
            illuminationDecay *= _Decay;
    
            result += sample1 * illuminationDecay * _Weight;
            illuminationDecay *= _Decay;
    
            result += sample2 * illuminationDecay * _Weight;
            illuminationDecay *= _Decay;
    
            sample0 = sample2;
        }
        result *= _Exposure * color;
        return result;
    }

    Оптимизация динамических теней


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

    Обычно, для расчета затенения для фрагмента изображения с использованием техники Shadow Mapping'а используется фильтр PCF [5]. Однако результат без дополнительного сглаживания дает только PCF с очень большим размером ядра, что неприемлемо для мобильных платформ. Более продвинутый метод Variance Shadow Mapping требует поддержки инструкций аппроксимации частных производных и билинейной фильтрации для floating-point текстур [6].

    Для получения мягких теней рендер всей видимой сцены выполняется дважды — в первый раз в offscreen-буфер рендерятся только тени, затем к offscreen-буферу применяется фильтр сглаживания, и после этого на экран рендерится цвет объектов, с учетом влияния тени из offscreen-буфера. Что приводит к двойной загрузке как CPU (отсечение, сортировка, обращение к драйверу) так и GPU.

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

    Для начала рендерим изображение в промежуточный буфер в формате RGBA (1). Значение альфы — отношение яркости цвета фрагмента если бы он был в тени, к яркости без тени (2). Затем, используя command buffer, перехватываем управление в момент завершения рендера непрозрачной геометрии, чтобы забрать альфу из буфера. Далее сглаживаем (3), и модулируем сглаженные тени с цветовыми каналами промежуточного буфера (4). После этого возобновляется работа пайплайна Unity: рендерятся прозрачные объекты и скайбокс (5).



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

    // shadow = 0..1
    // spec - specular lighting
    // diff - diffuse lighting
    
    fixed4 c = tex2D( _MainTex, i.uv );
    fixed3 ambDiffuse = c.xyz * UNITY_LIGHTMODEL_AMBIENT;
    fixed3 diffuseColor = _LightColor0.rgb * diff + UNITY_LIGHTMODEL_AMBIENT;
    fixed3 specularColor = _LightColor0.rgb * spec * shadow;
    c.rgb = saturate( c.rgb * diffuseColor + specularColor );
    c.a = Luminance( ambDiffuse / c.rgb );

    В результате мы получили заметный прирост производительности (10-15%) на устройствах средней производительности (в основном на андроидах), и на ряде устройств уменьшилась теплоотдача. Данная техника — это промежуточное решение, до перехода на отложенное освещение.

    Для съемки промо, мы по прежнему используем более качественный вариант, т.к. деградация цветопередачи там нежелательна, а ресурсов PС хватает. Для улучшения мягкости тени в этом случае мы применили следующее: при наложении тени используется формула, учитывающая LDotN, что позволяет добиться более плавного перехода в освещенных местах.

    fixed shLDotN = lerp( clamp( shadow, 0, LDotN ), LDotN * shadow, 1 - LDotN);

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



    Ссылки


    [1] GPU Gems developer.nvidia.com/gpugems/GPUGems/gpugems_pref01.html
    [2] Unity3D Post Processing Stack github.com/Unity-Technologies/PostProcessing
    [3] Кэш RenderTexture docs.unity3d.com/ScriptReference/RenderTexture.html
    [4] Volumetric light scattering as Post-Process http.developer.nvidia.com/GPUGems3/gpugems3_ch13.html
    [5] Percentage-close filtering http.developer.nvidia.com/GPUGems/gpugems_ch11.html
    [6] Summed-Area Variance Shadow Maps http.developer.nvidia.com/GPUGems3/gpugems3_ch08.html

    PS


    Отдельную благодарность необходимо предоставить Игорю Полищуку, который, собственно и придумал все описанные здесь хитрости, связанные с тенями, и, кроме того, участвовал в написании этой статьи.
    Метки:
    • +46
    • 14,7k
    • 8
    Pixonic 91,49
    Международная компания по разработке мобильных игр
    Поделиться публикацией

    Вакансии компании Pixonic

    Комментарии 8
    • +3
      Слегка «освежеванный» скриншот War Robots.

      Мне одному картинка слева нравится больше?
      И на КДПВ тоже.
      • +7
        Это норм. Настройки пост-процессинга в обоих случаях выполнены профессиональным программистом.
        • +2
          Конкретно первая картинка справа нравится больше. Слева либо как будто пылевая буря, либо как в 3D редакторе, когда текстуры облегченные.

          Конечно бывает, что слишком много постобработки в игре. Но сравнивать лучше именно в процессе игры, а не про скриншотам. В движении все (обычно) смотрится органичнее. Более того, если процесс увлекательный, на графику особого внимания и не обращаешь.
          • +4
            «освежеванный» в данном контексте употребляется как «плохо», так что нет, вы не один, автор так и задумывал.
            На КДПВ справа однозначно лучше.
          • 0
            Игра отличная, кстати.
            • 0
              Еще бы прикрутили возможность играть джойстиком и биндить ему кнопки, вообще бы цены не было.
              Джойстиком конечно можно играть и сейчас, но в меню он не работает, можно только двигаться и стрелять всем оружием сразу.
            • +1
              Очень интересная стратья!
              Спасибо
              • 0

                Дайте возможность крутить настройки графики, оптимизатор не справляется (

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое