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

от автора

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

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

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


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

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

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

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

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

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

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

GPU миллисекунды GPU миллисекунды
Adreno 320 5.2 Adreno 512 0.74
PowerVR G6200 3.3 Adreno 615 0.7
Mali-400 3.2 Adreno 530 0.4
Mali-T720 1.9 Mali-G51 0.32
PowerVR SXG 544 1.4 Mali-T830 0.15

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

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

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

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

ссылка на оригинал статьи https://habr.com/ru/company/playrix/blog/492874/


Комментарии

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

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