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 con OpenGL. Con Vulkan, debes implementar de forma explícita elementos 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. Existen tres maneras en las que Android puede conciliar la superficie de renderización del dispositivo con la orientación de este:

  1. El SO Android puede usar la unidad de procesamiento de pantalla (DPU) del dispositivo, que puede controlar de manera eficiente la rotación de la superficie en el hardware. Solo está disponible en dispositivos compatibles.
  2. El SO Android puede controlar la rotación de la superficie agregando un pase del compositor. Esto tendrá un costo de rendimiento según cómo el compositor deba manejar la rotación de la imagen de salida.
  3. La aplicación en sí puede controlar la rotación de la superficie renderizando una imagen rotada en una superficie de renderización que coincida con la orientación actual de la pantalla.

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

Actualmente, no hay forma de que una aplicación sepa si la rotación de la superficie que se controla fuera de la aplicación será gratuita. 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 al aumento del uso de GPU por parte de Android Compositor, que, por lo general, se ejecuta a una frecuencia aumentada. 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 se ejecutan títulos de envío en Pixel 4XL, vimos que SurfaceFlinger (la tarea de mayor prioridad que impulsa el Compositor de Android):

  • Interrumpe con regularidad el trabajo de la aplicación, lo que provoca resultados de 1 a 3 ms en la latencia de fotogramas y

  • Ejerce una mayor presión sobre la memoria de vértice o de textura de la GPU, ya que el compositor debe leer todo el 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 superficie se manejen de forma adecuada 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. De esta manera, se evita que Android Compositor realice la rotación por sí mismo.

Saber cómo establecer la marca de transformación de superficie es importante para todas las aplicaciones de Vulkan. Las aplicaciones tienden a admitir varias orientaciones o a una sola orientación en la que la superficie de renderización tiene una orientación diferente de la 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 el atributo screenOrientation, no es necesario que lo hagas. Además, si tu aplicación usa una orientación fija, solo necesitará configurar la cadena de intercambio una vez cuando se inicie o se reanude la 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 asociada 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. La forma más confiable de obtenerla es realizar una llamada a vkGetPhysicalDeviceSurfaceCapabilitiesKHR() cuando se inicia la aplicación y almacenar la extensión que se muestra. Cambia el ancho y la altura en función del currentTransform que también se muestra para asegurarte de almacenar la resolución de 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 muestran VK_SUBOPTIMAL_KHR de vkQueuePresentKHR(). Almacenamos el resultado de esta verificación en orientationChanged, un boolean al que se puede acceder desde el bucle de renderización principal de la aplicación.

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

En el caso de los dispositivos que ejecutan Android 10 o versiones anteriores, se necesita una implementación diferente, ya que VK_SUBOPTIMAL_KHR no es compatible.

Mediante consultas

En dispositivos con versiones anteriores a Android 10, puedes consultar la transformación actual del dispositivo cada pollingInterval fotogramas, donde pollingInterval es el nivel de detalle que el programador decide. 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 con esta solución es que solo se llama a onNativeWindowResized() para cambios de orientación de 90 grados, como cuando se pasa de horizontal a vertical o viceversa. 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 lo activará, lo que requiere que el compositor de Android realice el cambio de tu 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();
}

Realiza todo el trabajo necesario para recrear la cadena de intercambio dentro de la función OnOrientationChange(). Esto significa lo siguiente:

  1. Destruir cualquier instancia existente de Framebuffer y ImageView,

  2. Volver a crear la cadena de intercambio mientras destruyes la antigua (hablaremos sobre esto)

  3. Recrea los búferes de fotogramas con las DisplayImages de la nueva cadena de intercambio. Nota: Por lo general, no es necesario recrear las imágenes de archivos adjuntos (como las imágenes de profundidad o símbolos), ya que se basan en la resolución de identidad de las imágenes de la cadena de intercambio rotadas previamente.

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 con 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. También se puede usar el mismo cálculo con el objetivo de configurar la prueba de tijeras, que se incluye aquí para lograr una integridad:

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 se debe asignar a dFdy
    • dFdy se debe asignar a -dFdx
  • Para un marco con rotación previa de 270 grados:
    • dFdx se debe asignar a -dFdy
    • dFdy se debe asignar a dFdx
  • Para un marco con rotación previa de 180 grados:
    • dFdx se debe asignar 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, la marca de transformación previa esté configurada para coincidir 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 superficie de ventana de la app 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 de los dispositivos, ya que 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 lo necesite tu aplicación.

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