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

Что касается пост-обработки — её волшебное действие на фотографии было открыто задолго до появления первых компьютеров, а её математический и алгоритмический базис, созданный для цифровой обработки изображений, удачно вписался в программируемый конвейер 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


Отдельную благодарность необходимо предоставить Игорю Полищуку, который, собственно и придумал все описанные здесь хитрости, связанные с тенями, и, кроме того, участвовал в написании этой статьи.
Поделиться с друзьями
-->

Комментарии (8)


  1. IgeNiaI
    10.05.2017 15:13
    +3

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

    Мне одному картинка слева нравится больше?
    И на КДПВ тоже.


    1. morphiumsuchtig
      10.05.2017 15:49
      +7

      Это норм. Настройки пост-процессинга в обоих случаях выполнены профессиональным программистом.


    1. Tenebrius
      10.05.2017 15:54
      +2

      Конкретно первая картинка справа нравится больше. Слева либо как будто пылевая буря, либо как в 3D редакторе, когда текстуры облегченные.

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


    1. Torvald3d
      10.05.2017 16:06
      +4

      «освежеванный» в данном контексте употребляется как «плохо», так что нет, вы не один, автор так и задумывал.
      На КДПВ справа однозначно лучше.


  1. ShapovalovTS
    10.05.2017 16:15

    Игра отличная, кстати.


    1. almuerto
      11.05.2017 15:18

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


  1. Sergey99999
    10.05.2017 16:22
    +1

    Очень интересная стратья!
    Спасибо


  1. max_rip
    10.05.2017 22:05

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