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 los 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 del dispositivo:

  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. Disponible solo 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 la manera en que el compositor deba rotar la imagen de salida.
  3. La propia aplicación puede controlar la rotación de la superficie renderizando 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 la actualidad, no hay forma de que una aplicación sepa si la rotación de la superficie que se maneja 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 la 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 ejecutamos títulos de envío en el Pixel 4XL, observamos que SurfaceFlinger (la tarea de prioridad más alta que impulsa el compositor de Android) hace lo siguiente:

  • Anula el trabajo de la aplicación con frecuencia, lo que genera hits de 1 a 3 ms en los tiempos de fotogramas.

  • Aplica una mayor presión a la memoria de textura o vértice 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 correcta con la menor sobrecarga posible, como se ve en el caso anterior, debes implementar el método 3. Esto se conoce como prerotación. 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.

Para todas las aplicaciones de Vulkan, es importante saber cómo establecer la marca de transformación de superficie. Las aplicaciones tienden a admitir varias orientaciones o a admitir una sola 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 su pantalla con el atributo screenOrientation, no es necesario que hagas esto. 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 la 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 inicie la aplicación y almacenar la extensión que se muestre. 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 las aplicaciones.

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 requiere una implementación diferente, ya que no se admite VK_SUBOPTIMAL_KHR.

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 elige. 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 pasar 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 invertido no lo activará, lo que requerirá que el compositor de Android realice la inversión para 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();
}

Haces todo el trabajo necesario para volver a crear la cadena de intercambio dentro de la función OnOrientationChange(). Esto significa que puedes hacer lo siguiente:

  1. Destruye todas las instancias existentes de Framebuffer y ImageView.

  2. Volver a crear la cadena de intercambio al mismo tiempo que se destruye la anterior (lo cual explicaremos a continuación)

  3. Vuelve a crear los Framebuffers con las DisplayImages de la nueva cadena de intercambio. Nota: Por lo general, no es necesario volver a crear las imágenes de archivos adjuntos (por ejemplo, las imágenes de profundidad/símbolos), ya 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. También se puede utilizar el mismo cálculo para configurar la prueba de tijeras, que se incluye aquí para mayor 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 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, la marca de transformación previa se establezca para que coincida con la 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 sea necesario para tu aplicación.

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