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

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

With Vulkan , you can specify much more information about rendering state than you can with OpenGL. With Vulkan, you must explicitly implement things that are handled by the driver in OpenGL, such as device orientation and its relationship to render surface orientation . There are three ways that Android can handle reconciling the render surface of the device with the device orientation:

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

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

Currently, there's no way for an application to know whether surface rotation handled outside of the application will be free. Even if there is a DPU to take care of this for you, there will still likely be a measurable performance penalty to pay. If your application is CPU-bound, this becomes a power issue due to the increased GPU usage by the Android Compositor, which is usually running at a boosted frequency. If your application is GPU bound, then the Android Compositor can also preempt your application's GPU work, causing additional performance loss.

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

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

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

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

To ensure surface rotations are handled properly with as little overhead as possible, as seen in the preceding case, you should implement method 3. This is known as pre-rotation . This tells the Android OS that your app handles the surface rotation. You can do so by passing surface transform flags that specify the orientation during swapchain creation. This stops the Android Compositor from doing the rotation itself .

Knowing how to set the surface transform flag is important for every Vulkan application. Applications tend to either support multiple orientations or support a single orientation where the render surface is in a different orientation to what the device considers its identity orientation. For example, a landscape-only application on a portrait-identity phone, or a portrait-only application on a landscape-identity tablet.

Измените файл AndroidManifest.xml

To handle device rotation in your app, begin by changing the application's AndroidManifest.xml file to tell Android that your app will handle orientation and screen size changes. This prevents Android from destroying and recreating the Android Activity and calling the onDestroy() function on the existing window surface when an orientation change occurs. This is done by adding the orientation (to support API level <13) and screenSize attributes to the activity's configChanges section:

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

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

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

Next, detect the device's screen resolution associated with the VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR value. This resolution is associated with the identity orientation of the device, and is therefore the one that the swapchain will always need to be set to. The most reliable way to get this is to make a call to vkGetPhysicalDeviceSurfaceCapabilitiesKHR() at application startup, and store the returned extent. Swap the width and height based on the currentTransform that's also returned in order to ensure that you are storing the identity screen resolution:

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 функция vkQueuePresentKHR() возвращает значение VK_SUBOPTIMAL_KHR . Результат этой проверки сохраняется в orientationChanged , boolean , доступном из основного цикла рендеринга приложения.

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

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

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

On pre-Android 10 devices you can poll the current device transform every pollingInterval frames, where pollingInterval is a granularity decided on by the programmer. The way you do this is by calling vkGetPhysicalDeviceSurfaceCapabilitiesKHR() and then comparing the returned currentTransform field with that of the currently stored surface transformation (in this code example stored in 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;
}

The problem with this solution is that onNativeWindowResized() only gets called for 90-degree orientation changes, such as going from landscape to portrait or vice versa. Other orientation changes will not trigger the swapchain recreation. For example, a change from landscape to reverse-landscape will not trigger it, requiring the Android compositor to do the flip for your application.

Как справиться со сменой ориентации

Для обработки изменения ориентации экрана вызовите процедуру изменения ориентации в начале основного цикла отрисовки, когда переменная 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);

Рассмотрение вопроса - производные фрагментных шейдеров

If your application is using derivative computations such as dFdx and dFdy , additional transformations may be needed to account for the rotated coordinate system as these computations are executed in pixel space. This requires the app to pass some indication of the preTransform into the fragment shader (such as an integer representing the current device orientation) and use that to map the derivative computations properly:

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

Заключение

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

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

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