Оптимизация рендера под 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 стали применять другой подход. На тайловой архитектуре, свойственной мобильным видеокартам, рендер производится в небольшой участок памяти, соответствующей части экрана — тайлу. Малые размеры тайла (напр. 16x16 пикселей для видеокарт Mali, 32x32 для 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 2x2 тайлы 16x16 будут разрешаться, как 8x8 во время 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, а также разбирать приемы оптимизации шейдеров для них.
    Playrix
    Разработчик мобильных free-to-play игр

    Комментарии 2

      0
      Расскажите про инструменты профилировки графики, которые вы используете (кроме adb shell dumpsys surfaceflinger)
        +1

        Очень поверхностно и сыро. Ну упоминается в статье дважды Metal, и еще пару раз Vulkan. Но, о их преимуществах рессказано немного(ничего то есть), уже не говоря о профессиональных деталях. Не говорится также, что эти api не только способствуют росту FPS(если все это хорошенько распараллелить, и рисовать 'наперед'), но и уменьшают энергопотребление, что способствует возможности поиграть подольше. Обычно, у playrix все подробно, а тут, как мне показалось, довольно скудно.

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое