Cómo controlar la orientación del dispositivo con la rotación previa de Vulkan

En este artículo, se describe cómo controlar de manera eficiente la rotación del dispositivo en tu aplicación de Vulkan mediante la implementación de la rotación previa.

Con Vulkan, puedes especificar mucha más información sobre el estado de renderización que la que puedes usar con OpenGL. Con Vulkan, debes implementar explícitamente lo que controla el controlador en OpenGL, como la orientación del dispositivo y su relación con la orientación de la superficie de renderización. Android tiene tres maneras Controla la conciliación de la superficie de renderización del dispositivo con la orientación del dispositivo:

  1. El SO Android puede usar la unidad de procesamiento de visualización (DPU) del dispositivo. que puede controlar de forma eficiente la rotación de la superficie en el hardware. Disponible en solo en dispositivos compatibles.
  2. El SO Android puede controlar la rotación de superficie agregando un pase de compositor. Esta tendrán un costo de rendimiento según cómo deba trabajar el compositor y rotar la imagen de salida.
  3. La aplicación en sí puede controlar la rotación de la superficie renderizando un una imagen rotada sobre una superficie de renderización que coincida con la orientación actual de la pantalla.

¿Cuál de estos métodos deberías usar?

En este momento, no hay forma de que una aplicación sepa si la rotación de la superficie que se manejen fuera de la aplicación serán gratuitas. Incluso aunque haya una DPU que se ocupe de esto, es probable que se deba pagar una penalización medible de rendimiento. Si tu aplicación está vinculada a la CPU, esto se convierte en un problema de energía debido a el aumento en el uso de GPU por parte de Android Compositor, que generalmente se ejecuta a una más frecuencia. Si tu aplicación está vinculada a la GPU, Android Compositor también puede interrumpir el trabajo de GPU de tu aplicación, lo que provoca una pérdida adicional de rendimiento.

Cuando publicamos títulos de envío en el Pixel 4XL, hemos visto SurfaceFlinger (la tarea de mayor prioridad que controla la versión Compositor):

  • Interrumpe con regularidad el trabajo de la aplicación, lo que genera entre 1 ms y 3 ms hits a latencias de fotogramas y

  • Aplica más presión en las GPU de vértice o de textura, ya que el Compositor tiene que leer toda la búfer de fotogramas para realizar su trabajo de composición.

Cuando la orientación se controla de manera correcta, SurfaceFlinger detiene la interrupción de la GPU casi por completo, y la frecuencia de GPU disminuye un 40%, dado que ya no se necesita la frecuencia aumentada que usa Android Compositor.

Para garantizar que las rotaciones de la superficie se manipulen correctamente con la menor sobrecarga posible como se ve en el caso anterior, debes implementar el método 3. Esto se conoce como rotación previa. Esto le indica al SO Android que tu app controla la rotación de la superficie. Puedes hacerlo pasando marcas de transformación de superficie que especifiquen la orientación durante la creación de la cadena de intercambio. Esto detiene la que Android Compositor realice la rotación por sí mismo.

Es importante que sepas cómo configurar la marca de transformación de superficie para todos los objetos y mantener la integridad de su aplicación. Las aplicaciones tienden a admitir varias orientaciones o admiten una sola orientación en la que la superficie de renderización se encuentra en un diferente a lo que el dispositivo considera su orientación de identidad. Por ejemplo, una aplicación con orientación solo horizontal en un teléfono de identidad vertical o una aplicación con orientación solo vertical en una tablet de identidad horizontal.

Cómo modificar AndroidManifest.xml

Para controlar la rotación del dispositivo en tu app, primero cambia el archivo AndroidManifest.xml de la aplicación a fin de indicar a Android que la app gestionará los cambios de orientación y de tamaño de la pantalla. De esta manera, evitarás que Android destruya y vuelva a crear la Activity de Android y llame a la función onDestroy() en la superficie de la ventana existente cuando se produzca un cambio de orientación. Para tal fin, agrega los atributos orientation (de modo que se admitan los niveles de API inferiores a 13) y screenSize a la sección configChanges de la actividad:

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

Si tu aplicación corrige la orientación de la pantalla con screenOrientation. no es necesario que lo hagas. Además, si tu aplicación usa una configuración solo deberá configurar la cadena de intercambio una vez inicio o reanudación de una aplicación.

Cómo obtener la resolución de la pantalla de identidad y los parámetros de la cámara

A continuación, detecta la resolución de pantalla del dispositivo asociado con el valor VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR. Esta resolución se relaciona con la orientación de identidad del dispositivo; por lo tanto, es necesario configurar la cadena de intercambio en función de ella. El más una forma confiable de obtener esto es hacer una llamada a vkGetPhysicalDeviceSurfaceCapabilitiesKHR() al inicio de la aplicación y almacenarás la extensión que se devuelve. Cambia el ancho y el alto según la currentTransform que también se devuelve para garantizar que almacenes la resolución de la pantalla de identidad:

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 es una estructura VkExtent2D que usamos para almacenar esa resolución de identidad correspondiente a la superficie de la ventana de la app en la orientación natural de la pantalla.

Cómo detectar cambios en la orientación del dispositivo (Android 10 y versiones posteriores)

La forma más confiable de detectar un cambio de orientación en tu aplicación es comprobar si la función vkQueuePresentKHR() muestra VK_SUBOPTIMAL_KHR. Por ejemplo:

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

Nota: Esta solución solo funciona en dispositivos que ejecutan Android 10 y versiones posteriores Estas versiones de Android devuelven VK_SUBOPTIMAL_KHR de vkQueuePresentKHR(). Almacenamos el resultado de esto registrar en orientationChanged, un boolean al que se puede acceder desde aplicaciones' bucle de renderización principal.

Cómo detectar cambios en la orientación del dispositivo (versiones anteriores a Android 10)

En el caso de los dispositivos con Android 10 o versiones anteriores, se aplica un por lo que se requiere implementación, ya que no se admite VK_SUBOPTIMAL_KHR.

Mediante consultas

En dispositivos con versiones anteriores a Android 10, puedes sondear la transformación actual del dispositivo cada Fotogramas pollingInterval, en los que pollingInterval es un nivel de detalle que se decide por el programador. Para hacer esto, llama a vkGetPhysicalDeviceSurfaceCapabilitiesKHR() y, luego, compara el campo currentTransform mostrado con el de la transformación de superficie almacenada en ese momento (en este ejemplo de código, está almacenado en pretransformFlag).

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

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

En un Pixel 4 que ejecuta Android 10, consultar vkGetPhysicalDeviceSurfaceCapabilitiesKHR() llevó entre 0.120 ms y 0.250 ms, y en un Pixel 1XL que ejecuta Android 8, la consulta tardó entre 0.110 ms y 0.350 ms.

Mediante devoluciones de llamada

Una segunda opción para los dispositivos que ejecutan versiones anteriores a Android 10 es registrar una devolución de llamada onNativeWindowResized() a fin de llamar a una función que configure la marca orientationChanged, lo que indicará a la aplicación que se produjo un cambio de orientación:

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

ResizeCallback se define de la siguiente manera:

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

El problema de esta solución es que onNativeWindowResized() solo obtiene que requieren cambios de orientación de 90 grados, como pasar de horizontal a vertical o vice versa. Otros cambios de orientación no activarán la recreación de la cadena de intercambio. Por ejemplo, un cambio de horizontal a horizontal inverso no activarla, lo cual requiere que el compositor de Android haga el cambio por tu y mantener la integridad de su aplicación.

Cómo manejar los cambios de orientación

Para manejar los cambios de orientación, llama a la rutina de cambios de orientación en la parte superior del bucle de renderización principal cuando la variable orientationChanged esté configurada como verdadera. Por ejemplo:

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

Haces todo el trabajo necesario para recrear la cadena de intercambio la función OnOrientationChange(). Esto significa que:

  1. Destruye cualquier instancia existente de Framebuffer y ImageView.

  2. Cómo recrear la cadena de intercambio mientras se destruye la antigua cadena de intercambio (que hablaremos a continuación)

  3. Recrea los búferes de fotogramas con DisplayImages de la nueva cadena de intercambio. Nota: Por lo general, las imágenes de archivos adjuntos (por ejemplo, imágenes de profundidad o de símbolos) deben recrearse a medida que se basan en la resolución de identidad de las imágenes de la cadena de intercambio con rotación previa.

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;
}

Y, al final de la función, restablecerás la marca orientationChanged a falso para mostrar que has manejado el cambio de orientación.

Cómo volver a crear la cadena de intercambio

En la sección anterior, mencionamos que debíamos volver a crear la cadena de intercambio. Los primeros pasos para hacer esto consisten en obtener las características nuevas de la superficie de renderización:

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

Una vez que la estructura de VkSurfaceCapabilities se propagó con la información nueva, puedes verificar si se produjo un cambio de orientación observando el campo currentTransform. Almacenarás esto en el campo pretransformFlag a fin de usarlo más adelante, ya que lo necesitarás cuando ajustes la matriz de MVP.

Para hacerlo, especifica los siguientes atributos en la estructura de 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);
}

El campo imageExtent se propagará con la extensión de displaySizeIdentity que almacenaste al iniciarse la aplicación. El campo preTransform se propagará con la variable pretransformFlag (que se establece en el campo currentTransform de las surfaceCapabilities). También debes establecer el campo oldSwapchain en la cadena de intercambio que se destruirá.

Cómo ajustar la matriz de MVP

Lo último que debes hacer es aplicar la transformación previa aplicando una matriz de rotación a tu matriz de MVP. Lo que esto hace en realidad es aplicar la rotación en el espacio de recorte de modo que la imagen resultante rote a la orientación actual del dispositivo. Luego, simplemente, puedes pasar esta matriz de MVP actualizada a tu sombreador de vértices y usarla con normalidad sin la necesidad de modificar tus sombreadores.

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;

Consideración: Viewport y Scissor no abarcan la pantalla completa

Si tu aplicación usa una región de tijera o viewport que no es de pantalla completa, deberá actualizarse según la orientación del dispositivo. Para ello, debes habilitar las opciones dinámicas Viewport y Scissor durante la creación de la canalización de 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);

El cálculo real de la extensión del viewport durante la grabación del búfer de comandos se ve de la siguiente manera:

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);

Las variables x y y definen las coordenadas de la esquina superior izquierda del viewport, mientras que w y h definen el ancho y el alto del viewport, respectivamente. El mismo cálculo también se puede usar para configurar la prueba de tijeras, y se incluye aquí para ver la información completa:

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);

Consideración: Derivadas del sombreador de fragmentos

Si tu aplicación utiliza cálculos derivados, como dFdx y dFdy, es posible que se necesiten transformaciones adicionales para dar cuenta del sistema rotado de coordenadas, ya que estos cálculos se ejecutan en el espacio de píxeles. Esta opción requiere que la app pase alguna indicación de la transformación previa al sombreador de fragmentos (como un número entero que represente la orientación actual del dispositivo) y la use a fin de mapear los cálculos derivados de manera correcta:

  • Para un marco con rotación previa de 90 grados:
    • dFdx debe asignarse a dFdy
    • dFdy debe asignarse a -dFdx
  • Para un marco con rotación previa de 270 grados:
    • dFdx debe asignarse a -dFdy
    • dFdy debe asignarse a dFdx
  • Para un marco con rotación previa de 180 grados:
    • dFdx debe asignarse a -dFdx
    • dFdy se debe asignar a -dFdy

Conclusión

Para que tu aplicación aproveche Vulkan al máximo en Android, resulta fundamental que implementes la rotación previa. Las conclusiones más importantes de este artículo son las siguientes:

  • Asegúrate de que, durante la creación o recreación de la cadena de intercambio, se muestre la marca de transformación previa para que coincida con la marca que muestra el sistema operativo Android. Esto evitará la sobrecarga del compositor.
  • Mantén el tamaño de la cadena de intercambio fijo en la resolución de identidad de la ventana de la app superficie en la orientación natural de la pantalla.
  • Rota la matriz de MVP en el espacio de recorte para tener en cuenta la orientación del dispositivo porque la resolución o extensión de la cadena de intercambio ya no se actualiza con la orientación de la pantalla.
  • Actualiza los rectángulos de viewport y scissor según sea necesario para tu aplicación.

App de ejemplo: Rotación previa mínima de Android