Управление ориентацией устройства с помощью предварительного вращения Vulkan

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

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

  1. ОС Android может использовать процессор дисплея (DPU) устройства, который может эффективно обрабатывать вращение поверхности аппаратно. Доступно только на поддерживаемых устройствах.
  2. ОС Android может обрабатывать вращение поверхности, добавляя проход композитора. Это повлечет за собой снижение производительности в зависимости от того, как наборщику приходится обращаться с поворотом выходного изображения.
  3. Само приложение может обрабатывать вращение поверхности, отображая повернутое изображение на поверхности рендеринга, которая соответствует текущей ориентации дисплея.

Какой из этих методов вам следует использовать?

В настоящее время приложение не может узнать, будет ли вращение поверхности, обрабатываемое вне приложения, бесплатным. Даже если есть DPU, который позаботится об этом за вас, скорее всего, придется заплатить измеримый штраф за производительность. Если ваше приложение привязано к процессору, это становится проблемой с питанием из-за увеличения использования графического процессора Android Compositor, который обычно работает на повышенной частоте. Если ваше приложение привязано к графическому процессору, то Android Compositor также может вытеснить работу графического процессора вашего приложения, что приведет к дополнительной потере производительности.

При запуске игр на Pixel 4XL мы увидели, что SurfaceFlinger (задача с более высоким приоритетом, которая управляет Android Compositor):

  • Регулярно вытесняет работу приложения, вызывая нарушения времени кадра на 1–3 мс и

  • Увеличивает нагрузку на память вершин/текстур графического процессора, поскольку композитору приходится читать весь буфер кадра для выполнения работы по композиции.

Правильная обработка ориентации практически полностью останавливает вытеснение графического процессора с помощью SurfaceFlinger, а частота графического процессора падает на 40 %, поскольку повышенная частота, используемая Android Compositor, больше не нужна.

Чтобы обеспечить правильную обработку поворотов поверхности с минимальными издержками, как показано в предыдущем случае, вам следует реализовать метод 3. Это известно как предварительное вращение . Это сообщает ОС Android, что ваше приложение обрабатывает вращение поверхности. Вы можете сделать это, передав флаги преобразования поверхности, которые определяют ориентацию во время создания цепочки обмена. Это не позволяет Android Compositor выполнять вращение самостоятельно .

Знание того, как установить флаг преобразования поверхности, важно для каждого приложения Vulkan. Приложения, как правило, либо поддерживают несколько ориентаций, либо поддерживают одну ориентацию, при которой поверхность рендеринга находится в ориентации, отличной от той, которую устройство считает своей ориентацией. Например, приложение только с альбомной ориентацией на телефоне с книжной ориентацией или приложение только с книжной ориентацией на планшете с альбомной ориентацией.

Измените AndroidManifest.xml.

Чтобы управлять поворотом устройства в вашем приложении, начните с изменения файла AndroidManifest.xml приложения, чтобы сообщить Android, что ваше приложение будет обрабатывать изменения ориентации и размера экрана. Это не позволяет Android уничтожить и воссоздать Activity Android, а также вызвать функцию onDestroy() на существующей поверхности окна при изменении ориентации. Это делается путем добавления атрибутов orientation (для поддержки уровня API <13) и screenSize в раздел configChanges действия:

<activity android:name="android.app.NativeActivity"
          android:configChanges="orientation|screenSize">

Если ваше приложение фиксирует ориентацию экрана с помощью атрибута screenOrientation , вам не нужно этого делать. Кроме того, если ваше приложение использует фиксированную ориентацию, ему нужно будет настроить цепочку обмена только один раз при запуске/возобновлении приложения.

Получите разрешение экрана идентификации и параметры камеры.

Затем определите разрешение экрана устройства, связанное со значением VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR . Это разрешение связано с идентификационной ориентацией устройства и, следовательно, именно оно всегда должно быть установлено в цепочке обмена. Самый надежный способ получить это — вызвать vkGetPhysicalDeviceSurfaceCapabilitiesKHR() при запуске приложения и сохранить возвращенный экстент. Поменяйте местами ширину и высоту на основе currentTransform , который также возвращается, чтобы гарантировать сохранение разрешения экрана идентификации:

VkSurfaceCapabilitiesKHR capabilities;
vkGetPhysicalDeviceSurfaceCapabilitiesKHR(physDevice, surface, &capabilities);

uint32_t width = capabilities.currentExtent.width;
uint32_t height = capabilities.currentExtent.height;
if (capabilities.currentTransform & VK_SURFACE_TRANSFORM_ROTATE_90_BIT_KHR ||
    capabilities.currentTransform & VK_SURFACE_TRANSFORM_ROTATE_270_BIT_KHR) {
  // Swap to get identity width and height
  capabilities.currentExtent.height = width;
  capabilities.currentExtent.width = height;
}

displaySizeIdentity = capabilities.currentExtent;

displaySizeIdentity — это структура VkExtent2D , которую мы используем для хранения указанного идентификационного разрешения поверхности окна приложения в естественной ориентации дисплея.

Обнаружение изменений ориентации устройства (Android 10+)

Самый надежный способ обнаружить изменение ориентации в вашем приложении — проверить, возвращает ли функция vkQueuePresentKHR() VK_SUBOPTIMAL_KHR . Например:

auto res = vkQueuePresentKHR(queue_, &present_info);
if (res == VK_SUBOPTIMAL_KHR){
  orientationChanged = true;
}

Примечание. Это решение работает только на устройствах под управлением Android 10 и более поздних версий. Эти версии Android возвращают VK_SUBOPTIMAL_KHR из vkQueuePresentKHR() . Мы сохраняем результат этой проверки в orientationChangedboolean , доступном из основного цикла рендеринга приложения.

Обнаружение изменений ориентации устройства (до Android 10)

Для устройств под управлением Android 10 или более ранней версии необходима другая реализация, поскольку VK_SUBOPTIMAL_KHR не поддерживается.

Использование опроса

На устройствах до Android 10 вы можете опросить текущее преобразование устройства в каждом кадре pollingInterval , где pollingInterval — это степень детализации, выбранная программистом. Это можно сделать, вызвав vkGetPhysicalDeviceSurfaceCapabilitiesKHR() и затем сравнив возвращаемое поле currentTransform с полем текущего сохраненного преобразования поверхности (в этом примере кода, хранящегося в pretransformFlag ).

currFrameCount++;
if (currFrameCount >= pollInterval){
  VkSurfaceCapabilitiesKHR capabilities;
  vkGetPhysicalDeviceSurfaceCapabilitiesKHR(physDevice, surface, &capabilities);

  if (pretransformFlag != capabilities.currentTransform) {
    window_resized = true;
  }
  currFrameCount = 0;
}

На Pixel 4 под управлением Android 10 опрос vkGetPhysicalDeviceSurfaceCapabilitiesKHR() занял от 0,120 до 0,250 мс, а на Pixel 1XL под управлением Android 8 опрос занял 0,110–0,350 мс.

Использование обратных вызовов

Второй вариант для устройств под управлением Android 10 — зарегистрировать обратный вызов onNativeWindowResized() для вызова функции, которая устанавливает флаг orientationChanged , сигнализируя приложению, что произошло изменение ориентации:

void android_main(struct android_app *app) {
  ...
  app->activity->callbacks->onNativeWindowResized = ResizeCallback;
}

Где ResizeCallback определяется как:

void ResizeCallback(ANativeActivity *activity, ANativeWindow *window){
  orientationChanged = true;
}

Проблема с этим решением заключается в том, что onNativeWindowResized() вызывается только для изменения ориентации на 90 градусов, например, при переходе с альбомной на портретную или наоборот. Другие изменения ориентации не приведут к воссозданию цепочки обмена. Например, изменение ландшафта на обратный ландшафт не вызовет его, требуя, чтобы компоновщик Android выполнил переворот для вашего приложения.

Обработка изменения ориентации

Чтобы обработать изменение ориентации, вызовите процедуру изменения ориентации в верхней части основного цикла рендеринга, когда для переменной orientationChanged установлено значение true. Например:

bool VulkanDrawFrame() {
 if (orientationChanged) {
   OnOrientationChange();
}

Всю работу, необходимую для воссоздания цепочки обмена, вы выполняете с помощью функции OnOrientationChange() . Это означает, что вы:

  1. Уничтожьте все существующие экземпляры Framebuffer и ImageView .

  2. Воссоздать цепочку обмена, уничтожив старую цепочку обмена (о которой речь пойдет далее), и

  3. Воссоздайте фреймбуферы с помощью DisplayImages новой цепочки обмена. Примечание. Изображения вложений (например, изображения глубины/трафарета) обычно не нужно воссоздавать, поскольку они основаны на идентификационном разрешении предварительно повернутых изображений цепочки обмена.

void OnOrientationChange() {
 vkDeviceWaitIdle(getDevice());

 for (int i = 0; i < getSwapchainLength(); ++i) {
   vkDestroyImageView(getDevice(), displayViews_[i], nullptr);
   vkDestroyFramebuffer(getDevice(), framebuffers_[i], nullptr);
 }

 createSwapChain(getSwapchain());
 createFrameBuffers(render_pass, depthBuffer.image_view);
 orientationChanged = false;
}

И в конце функции вы сбрасываете флаг orientationChanged в значение false, чтобы показать, что вы обработали изменение ориентации.

Отдых в Swapchain

В предыдущем разделе мы упоминали о необходимости воссоздать цепочку обмена. Первые шаги для этого включают получение новых характеристик поверхности рендеринга:

void createSwapChain(VkSwapchainKHR oldSwapchain) {
   VkSurfaceCapabilitiesKHR capabilities;
   vkGetPhysicalDeviceSurfaceCapabilitiesKHR(physDevice, surface, &capabilities);
   pretransformFlag = capabilities.currentTransform;

Теперь, когда структура VkSurfaceCapabilities заполнена новой информацией, вы можете проверить, произошло ли изменение ориентации, проверив поле currentTransform . Вы сохраните его для дальнейшего использования в поле pretransformFlag , так как он понадобится вам позже, когда вы будете вносить изменения в матрицу MVP.

Для этого в структуре VkSwapchainCreateInfo укажите следующие атрибуты:

VkSwapchainCreateInfoKHR swapchainCreateInfo{
  ...
  .sType = VK_STRUCTURE_TYPE_SWAPCHAIN_CREATE_INFO_KHR,
  .imageExtent = displaySizeIdentity,
  .preTransform = pretransformFlag,
  .oldSwapchain = oldSwapchain,
};

vkCreateSwapchainKHR(device_, &swapchainCreateInfo, nullptr, &swapchain_));

if (oldSwapchain != VK_NULL_HANDLE) {
  vkDestroySwapchainKHR(device_, oldSwapchain, nullptr);
}

Поле imageExtent будет заполнено экстентом displaySizeIdentity , который вы сохранили при запуске приложения. Поле preTransform будет заполнено переменной pretransformFlag (которая установлена ​​в поле currentTransform surfaceCapabilities ). Вы также устанавливаете поле oldSwapchain для цепочки обмена, которая будет уничтожена.

Корректировка матрицы MVP

Последнее, что вам нужно сделать, это применить предварительное преобразование, применив матрицу вращения к вашей матрице MVP. По сути, это применяет вращение в пространстве клипа, чтобы полученное изображение было повернуто в соответствии с текущей ориентацией устройства. Затем вы можете просто передать эту обновленную матрицу MVP в свой вершинный шейдер и использовать ее как обычно, без необходимости изменять свои шейдеры.

glm::mat4 pre_rotate_mat = glm::mat4(1.0f);
glm::vec3 rotation_axis = glm::vec3(0.0f, 0.0f, 1.0f);

if (pretransformFlag & VK_SURFACE_TRANSFORM_ROTATE_90_BIT_KHR) {
  pre_rotate_mat = glm::rotate(pre_rotate_mat, glm::radians(90.0f), rotation_axis);
}

else if (pretransformFlag & VK_SURFACE_TRANSFORM_ROTATE_270_BIT_KHR) {
  pre_rotate_mat = glm::rotate(pre_rotate_mat, glm::radians(270.0f), rotation_axis);
}

else if (pretransformFlag & VK_SURFACE_TRANSFORM_ROTATE_180_BIT_KHR) {
  pre_rotate_mat = glm::rotate(pre_rotate_mat, glm::radians(180.0f), rotation_axis);
}

MVP = pre_rotate_mat * MVP;

Рекомендации — неполноэкранное окно просмотра и ножницы

Если ваше приложение использует не полноэкранную область просмотра или область ножниц, их необходимо обновить в соответствии с ориентацией устройства. Для этого необходимо включить параметры динамического просмотра и ножниц во время создания конвейера Vulkan:

VkDynamicState dynamicStates[2] = {
  VK_DYNAMIC_STATE_VIEWPORT,
  VK_DYNAMIC_STATE_SCISSOR,
};

VkPipelineDynamicStateCreateInfo dynamicInfo = {
  .sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO,
  .pNext = nullptr,
  .flags = 0,
  .dynamicStateCount = 2,
  .pDynamicStates = dynamicStates,
};

VkGraphicsPipelineCreateInfo pipelineCreateInfo = {
  .sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO,
  ...
  .pDynamicState = &dynamicInfo,
  ...
};

VkCreateGraphicsPipelines(device, VK_NULL_HANDLE, 1, &pipelineCreateInfo, nullptr, &mPipeline);

Фактический расчет размера области просмотра во время записи в буфер команд выглядит следующим образом:

int x = 0, y = 0, w = 500, h = 400;

glm::vec4 viewportData;

switch (device->GetPretransformFlag()) {
  case VK_SURFACE_TRANSFORM_ROTATE_90_BIT_KHR:
    viewportData = {bufferWidth - h - y, x, h, w};
    break;
  case VK_SURFACE_TRANSFORM_ROTATE_180_BIT_KHR:
    viewportData = {bufferWidth - w - x, bufferHeight - h - y, w, h};
    break;
  case VK_SURFACE_TRANSFORM_ROTATE_270_BIT_KHR:
    viewportData = {y, bufferHeight - w - x, h, w};
    break;
  default:
    viewportData = {x, y, w, h};
    break;
}

const VkViewport viewport = {
    .x = viewportData.x,
    .y = viewportData.y,
    .width = viewportData.z,
    .height = viewportData.w,
    .minDepth = 0.0F,
    .maxDepth = 1.0F,
};

vkCmdSetViewport(renderer->GetCurrentCommandBuffer(), 0, 1, &viewport);

Переменные x и y определяют координаты верхнего левого угла области просмотра, а w и h определяют ширину и высоту области просмотра соответственно. Это же вычисление также можно использовать для установки теста на ножницы, и оно включено сюда для полноты картины:

int x = 0, y = 0, w = 500, h = 400;
glm::vec4 scissorData;

switch (device->GetPretransformFlag()) {
  case VK_SURFACE_TRANSFORM_ROTATE_90_BIT_KHR:
    scissorData = {bufferWidth - h - y, x, h, w};
    break;
  case VK_SURFACE_TRANSFORM_ROTATE_180_BIT_KHR:
    scissorData = {bufferWidth - w - x, bufferHeight - h - y, w, h};
    break;
  case VK_SURFACE_TRANSFORM_ROTATE_270_BIT_KHR:
    scissorData = {y, bufferHeight - w - x, h, w};
    break;
  default:
    scissorData = {x, y, w, h};
    break;
}

const VkRect2D scissor = {
    .offset =
        {
            .x = (int32_t)viewportData.x,
            .y = (int32_t)viewportData.y,
        },
    .extent =
        {
            .width = (uint32_t)viewportData.z,
            .height = (uint32_t)viewportData.w,
        },
};

vkCmdSetScissor(renderer->GetCurrentCommandBuffer(), 0, 1, &scissor);

Соображение — производные фрагментных шейдеров

Если ваше приложение использует производные вычисления, такие как dFdx и dFdy , могут потребоваться дополнительные преобразования для учета повернутой системы координат, поскольку эти вычисления выполняются в пиксельном пространстве. Для этого приложение должно передать некоторую информацию о предварительном преобразовании во фрагментный шейдер (например, целое число, представляющее текущую ориентацию устройства) и использовать его для правильного сопоставления производных вычислений:

  • Для рамы, предварительно повернутой на 90 градусов.
    • dFdx должен быть сопоставлен с dFdy
    • dFdy должен быть сопоставлен с -dFdx
  • Для рамы, предварительно повернутой на 270 градусов.
    • dFdx должен быть сопоставлен с -dFdy
    • dFdy должен быть сопоставлен с dFdx
  • Для рамы, предварительно повернутой на 180 градусов ,
    • dFdx должен быть сопоставлен с -dFdx
    • dFdy должен быть сопоставлен с -dFdy

Заключение

Чтобы ваше приложение максимально эффективно использовало возможности Vulkan на Android, необходимо реализовать предварительную ротацию. Наиболее важные выводы из этой статьи:

  • Убедитесь, что во время создания или восстановления цепочки обмена флаг предварительного преобразования установлен в соответствии с флагом, возвращаемым операционной системой Android. Это позволит избежать накладных расходов на компоновщик.
  • Сохраняйте размер цепочки обмена фиксированным в соответствии с разрешением поверхности окна приложения в естественной ориентации дисплея.
  • Поверните матрицу MVP в пространстве отсечения, чтобы учесть ориентацию устройств, поскольку разрешение/экстент цепочки обмена больше не обновляется вместе с ориентацией дисплея.
  • Обновите область просмотра и прямоугольники-ножницы по мере необходимости вашего приложения.

Пример приложения: минимальная предварительная ротация Android