Оптимизация рендера под Mobile

Здравствуйте, дорогие читатели, любители и профессионалы программирования графики! Предлагаем вашему вниманию цикл статей, посвященных оптимизации рендера под мобильные устройства: телефоны и планшеты на базе iOS и Android. Цикл будет состоять из трех частей. В первой части мы рассмотрим особенности популярной на Mobile тайловой архитектуры GPU. Во второй пройдемся по основным семействам GPU, представленным в современных девайсах, и рассмотрим их слабые и сильные стороны. В третьей части мы познакомимся с особенностями оптимизации шейдеров.

Итак, приступим к первой части.

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

51e4fa969a3e5ede3b31064499c33226.png


Решение было найдено в особой архитектуре, получившей название Tile Based Rendering (TBR). Программисту графики с опытом разработки под ПК при знакомстве с мобильной разработкой все кажется знакомым: применяется похожее API OpenGL ES, такая же структура графического конвейера. Однако тайловая архитектура мобильных GPU существенно отличается от применяемой на ПК/консолях «Immediate Mode» архитектуры. Знание сильных и слабых мест TBR поможет принять правильные решения и получить отличную производительность под Mobile.

Ниже приведена упрощенная схема классического графического конвейера, применяемого на ПК и консолях уже третье десятилетие.

28167b5e2eaa4746ebd1ed45f8aa2dfb.png


На этапе обработки геометрии атрибуты вершин читаются из видеопамяти GPU. После различных преобразований (Vertex Shader) готовые к рендеру примитивы в исходном порядке (FIFO) передаются растеризатору, который разбивает примитивы на пиксели. После этого осуществляется этап фрагментной обработки каждого пикселя (Fragment Shader), и полученные значения цветов записываются в экранный буфер, который также размещается в видеопамяти. Особенностью традиционной архитектуры «Immediate Mode» является запись результата работы Fragment Shader в произвольные участки экранного буфера при обработке одного вызова отрисовки (draw call). Таким образом, для каждого вызова отрисовки может потребоваться доступ ко всему экранному буферу целиком. Работа с большим массивом памяти требует соответствующей пропускной способности шины (bandwidth) и связана с высоким потреблением электроэнергии. Поэтому в мобильных GPU стали применять другой подход. На тайловой архитектуре, свойственной мобильным видеокартам, рендер производится в небольшой участок памяти, соответствующей части экрана — тайлу. Малые размеры тайла (напр. 16×16 пикселей для видеокарт Mali, 32×32 для PowerVR), позволяют размещать его непосредственно в чипе видеокарты, что делает скорость доступа к нему сопоставимой со скоростью доступа к регистрам шейдерного ядра, т.е. очень быстрой.

86d7bfc65e53739ab9be0d5ea1242c0e.png


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

531ea500c507eb47558676582e20c94c.png


После обработки вершин и построения примитивов последние вместо отправки на фрагментный конвейер попадают в так называемый Tiler. Здесь происходит распределение примитивов по тайлам, в пиксели которого они попадают. После такого распределения, которое, как правило, охватывает все вызовы отрисовки, направленные в один Frame Buffer Object (aka Render Target), происходит поочередный рендер в тайлы. Для каждого тайла осуществляется такая последовательность действий:

  1. Загрузка старого содержимого FBO из системной памяти (Load
  2. Рендер примитивов, попадающих в этот тайл
  3. Выгрузка нового содержимого FBO в системную память (Store)


9ea20b74b9fb716af30d5e0fa4ee1b34.png


Следует заметить, что Load операцию можно рассматривать, как дополнительное наложение «полноэкранной текстуры» без сжатия. По возможности стоит избегать этой операции, т.е. не допускать переключение FBO «туда и обратно». Если перед рендером в FBO производится очистка всего его содержимого, Load операция не производится. Однако для отправки правильного сиглана драйверу параметры такой очистки должны отвечать определенным критериям:

  1. Должен быть отключен Scissor Rect
  2. Должна быть разрешена запись во все каналы цвета и альфу


Чтобы не происходила Load операция для буфера глубины и трафарета, их также необходимо очистить перед началом рендера.

Также возможно избежать операции Store для буфера глубины/трафарета. Ведь содержимое этих буферов никак не отображается на экране. Перед операцией glSwapBuffers можно вызвать glDiscardFramebufferEXT или glInvalidateFramebuffer

const GLenum attachments[] = {GL_DEPTH_ATTACHMENT, GL_STENCIL_ATTACHMENT};
glDiscardFramebufferEXT (GL_FRAMEBUFFER, 2, attachments);
const GLenum attachments[] = {GL_DEPTH_ATTACHMENT, GL_STENCIL_ATTACHMENT};
glInvalidateFramebuffer(GL_FRAMEBUFFER, 2, attachments);


Существуют сценарии рендера, при которых размещение буферов глубины/трафарета, а также MSAA буферов в системной памяти не требуется. Например, если рендер в FBO с буфером глубины идет непрерывно, и при этом информация о глубине из предыдущего кадра не используется, то буфер глубины не нужно как загружать в тайловую память до начала рендера, так и выгружать после завершения рендера. Следовательно, системную память под буфер глубины можно не выделять. Современные графические API, такие как Vulkan и Metal, позволяют явно задавать режим обеспечения памяти для своих аналогов FBO  (MTLStorageModeMemoryless в Metal, VK_IMAGE_USAGE_TRANSIENT_ATTACHMENT_BIT +VK_MEMORY_PROPERTY_LAZILY_ALLOCATED_BIT в Vulkan).

Особого внимания заслуживает реализация MSAA на тайловых архитектурах. Буфер повышенного разрешения для MSAA не покидает тайловую память за счет разбиения FBO на большее количество тайлов. Например, для MSAA 2×2 тайлы 16×16 будут разрешаться, как 8×8 во время Store операции, т.е. суммарно нужно будет обработать в 4 раза больше тайлов. Зато дополнительная память для MSAA не потребуется, а за счет рендера в быструю тайловую память не будет существенных ограничений по bandwidth. Однако использование MSAA на тайловой архитектуре повышает нагрузку на Tiler, что может негативно сказаться на производительности рендера сцен с большим количеством геометрии.

Резюмируя вышеописанное, приведем желательную схему работы с FBO на тайловой архитектуре:

// 1. начало нового кадра, рендерим во вспомогательный auxFBO
glBindFramebuffer(GL_FRAMEBUFFER, auxFBO);
glDisable(GL_SCISSOR);
glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
glDepthMask(GL_TRUE);
// glClear, который гарантированно очистит все содержимое
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | 
           GL_STENCIL_BUFFER_BIT);

renderAuxFBO();         

// содержимое буфера глубины/трафарета не нужно копировать в системную память
glInvalidateFramebuffer(GL_FRAMEBUFFER, 2, depth_and_stencil);
// 2. Рендер основного mainFBO
glBindFramebuffer(GL_FRAMEBUFFER, mainFBO);
glDisable(GL_SCISSOR);

glClear(...);
// рендер в mainFBO с использованием содержимого auxFBO
renderMainFBO(auxFBO);

glInvalidateFramebuffer(GL_FRAMEBUFFER, 2, depth_and_stencil);


Если же переключаться на рендер auxFBO посреди формирования mainFBO, можно получить лишние Load & Store операции, которые могут существенно увеличить время формирования кадра. В нашей практике мы столкнулись с замедлением рендера даже в случае холостых установок FBO, т.е. без фактического рендера в них. Из-за особенностей архитектуры движка наша старая схема выглядела так:

// холостая установка mainFBO
glBindFramebuffer(GL_FRAMEBUFFER, mainFBO);
// ничего не делается
glBindFramebuffer(GL_FRAMEBUFFER, auxFBO);
// формируем auxFBO
renderAuxFBO();

glBindFramebuffer(GL_FRAMEBUFFER, mainFBO);
// начинаем рендер mainFBO
renderMainFBO(auxFBO);


Несмотря на отсутствие gl вызовов после первой установки mainFBO, на некоторых девайсах мы получали лишние Load & Store операции и худшую производительность.

Чтобы улучшить наши представления об overhead от использования промежуточных FBO, мы замеряли потери времени на переключение полноэкранных FBO при помощи синтетического теста. В таблице приведено время, затрачиваемое на Store операцию при многократном переключении FBO в одном кадре (приведено время одной такой операции). Load операция отсутствовала за счет glClear, т.е. измерялся более благоприятный сценарий. Свой вклад вносило разрешение, используемое на девайсе. Оно могло в большей или меньшей степени соответствовать мощности установленного GPU. Поэтому данные цифры дают лишь общее представление о том, насколько дорогой операцией является переключение таргетов на мобильных видеокартах различных поколений.


Опираясь на полученные данные, можно прийти к рекомендации не использовать более одного-двух переключений FBO на кадр, как минимум для старых видеокарт. Если в игре присутствует отдельный code pass для Low-End устройств, желательно не использовать там смену FBO. Однако на Low-End часто становится актуальным вопрос понижения разрешения. На Android можно понизить разрешение рендера, не прибегая к использованию промежуточного FBO, при помощи вызова SurfaceHolder.setFixedSize ():

surfaceView.getHolder().setFixedSize(...)


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

JNIEXPORT void JNICALL Java_com_organization_app_AppNativeActivity_setBufferGeometry(JNIEnv *env, jobject thiz, jobject surface, jint width, jint height)
{
ANativeWindow* window = ANativeWindow_fromSurface(env, surface); 
ANativeWindow_setBuffersGeometry(window, width, height, AHARDWAREBUFFER_FORMAT_R8G8B8X8_UNORM); 
}


В Java:

private static native void setBufferGeometry(Surface surface, int width , int height ); 
...
// в наследнике SurfaceHolder.Callback
@Override public void surfaceChanged(SurfaceHolder holder, int format, int width, int height)
{
     setBufferGeometry(holder.getSurface(), 768, 1366); /* ... */
...


Напоследок упомянем удобную команду ADB для контроля за выделенными буферами поверхностей на Android:

adb shell dumpsys surfaceflinger


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

5242feea615abe186c6c3538c9d60565.png


На приведенном скриншоте видно выделение системой 3-х буферов для тройной буферизации GLSurfaceView игры (подсвечено желтым), а также 2-х буферов для основного Surface (подсвечено красным). В случае рендера через основной Surface, что является схемой «по умолчанию» при использовании NativeActivity, выделения дополнительных буферов можно избежать. 

На этом пока все. В следующих статьях мы будем классифицировать мобильные GPU, а также разбирать приемы оптимизации шейдеров для них.

© Habrahabr.ru