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