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

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

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

  1. ОС Android может использовать Display Processing Unit (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 уничтожать и заново создавать Android Activity и вызывать функцию onDestroy() на существующей поверхности окна при изменении ориентации. Это делается путем добавления атрибутов orientation (для поддержки уровня API <13) и screenSize в раздел configChanges активности:

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

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

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

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

Обнаружение изменений ориентации устройства (до 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. Пересоздайте Framebuffers с 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 Отдых

В предыдущем разделе мы упоминали необходимость пересоздать 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 в swapchain, который будет уничтожен.

Регулировка матрицы 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;

Рассмотрение — область просмотра, не занимающая весь экран, и обрезка

Если ваше приложение использует область viewport/scissor не на весь экран, их нужно будет обновить в соответствии с ориентацией устройства. Для этого необходимо включить динамические параметры Viewport и Scissor во время создания конвейера 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 , могут потребоваться дополнительные преобразования для учета повернутой системы координат, поскольку эти вычисления выполняются в пиксельном пространстве. Для этого приложение должно передать некоторое указание preTransform в шейдер фрагмента (например, целое число, представляющее текущую ориентацию устройства) и использовать его для правильного отображения производных вычислений:

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

Заключение

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

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

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