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

от автора

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

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


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *