Processar a orientação do dispositivo com pré-rotação do Vulkan

Este artigo descreve como processar a rotação de dispositivos de maneira eficiente no aplicativo Vulkan implementando a pré-rotação.

Com o Vulkan, você pode especificar muito mais informações sobre o estado de renderização do que é possível com o OpenGL. Com o Vulkan, você precisa implementar explicitamente os itens processados pelo driver em OpenGL, como a orientação do dispositivo e a relação dele com orientação da superfície de renderização (link em inglês). O Android pode alça reconciliando a superfície de renderização do dispositivo com a orientação do dispositivo:

  1. O SO Android pode usar a unidade de processamento de tela (DPU, na sigla em inglês) do dispositivo, que pode lidar de forma eficiente com a rotação de superfícies no hardware. Disponível em compatíveis.
  2. O SO Android pode processar a rotação de superfície adicionando uma passagem do compositor. Isso terão um custo de desempenho, dependendo de como o compositor precisa lidar com a rotação da imagem de saída.
  3. O próprio aplicativo pode processar a rotação da superfície renderizando uma girada imagem em uma superfície de renderização que corresponde à orientação atual de na tela.

Qual método você deve usar?

Atualmente, não há como um aplicativo saber se a rotação da superfície manuseadas fora do aplicativo serão sem custo financeiro. Mesmo que haja uma DPU para resolver esse problema, ainda haverá uma perda mensurável no desempenho. Se o aplicativo estiver limitado pela CPU, isso se tornará um problema de energia devido ao o aumento do uso de GPU pelo Android Compositor, que geralmente é executado em uma mais frequência. Se o aplicativo for vinculado à GPU, o Android Compositor também poderá forçar a interrupção do trabalho da GPU no aplicativo, prejudicando o desempenho.

Ao veicular títulos de frete no Pixel 4XL, notamos esse SurfaceFlinger, a tarefa de maior prioridade que orienta o sistema compositor):

  • Preia regularmente o trabalho do aplicativo, causando de 1 a 3 ms dos hits aos tempos de quadro e

  • Aumenta a pressão sobre as GPUs memória de vértice/textura, porque o combinável precisa ler toda a framebuffer para fazer seu trabalho de composição.

O processamento da orientação interrompe a preempção da GPU pelo SurfaceFlinger quase totalmente. Enquanto isso, a frequência da GPU diminui em 40% porque a frequência aumentada usada pelo Android Composer não é mais necessária.

Para garantir que as rotações de superfície sejam tratadas adequadamente com o mínimo de sobrecarga possível, como vimos no caso anterior, você deve implementar o método 3. Isso é conhecido como pré-rotação. Isso informa ao SO Android que seu app lida com a rotação da superfície. Você pode fazer isso transmitindo sinalizações de transformação da superfície que especificam a orientação durante a criação da cadeia de troca. Isso interrompe o o Android Compositor faça a rotação por conta própria.

Saber como definir a flag de transformação da superfície é importante para todos os Vulkan. para o aplicativo. Os aplicativos costumam oferecer suporte a várias orientações ou oferecer suporte a uma única orientação, em que a superfície de renderização está em um orientação de identidade com base no que o dispositivo considera como orientação de identidade. Por exemplo, um aplicativo somente em modo paisagem em um smartphone de identidade retrato ou um aplicativo somente em modo retrato em um tablet de identidade paisagem.

Modificar o AndroidManifest.xml

Para gerenciar a rotação de dispositivo no seu app, comece mudando o arquivo AndroidManifest.xml do aplicativo para informar ao Android que seu app processará as mudanças de tamanho da tela e de orientação. Isso evita que o Android destrua e recrie a Activity do Android e chame a função onDestroy() na superfície da janela existente quando ocorre uma mudança de orientação. Isso é feito adicionando os atributos orientation (para compatibilidade com o nível de API <13) e screenSize à seção configChanges da atividade:

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

Se o aplicativo corrigir a orientação da tela usando o método screenOrientation não é necessário fazer isso. Além disso, se o aplicativo usa um endereço IP orientação, ele só precisará configurar a cadeia de troca uma vez inicialização/retomada do aplicativo.

Acessar a resolução da tela de identidade e os parâmetros da câmera

Detectar a resolução da tela do dispositivo associadas ao valor VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR. Essa resolução está associada à orientação da identidade do dispositivo e, portanto, é aquela em que a cadeia de troca sempre precisará ser definida. A maior maneira confiável de conseguir isso é fazer uma chamada para vkGetPhysicalDeviceSurfaceCapabilitiesKHR() na inicialização do aplicativo armazenar a extensão retornada. Troque a largura e a altura com base no currentTransform que também é retornado para garantir o armazenamento a resolução da tela de identidade:

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 é uma estrutura de VkExtent2D que usamos para armazenar essa resolução de identidade da superfície da janela do app na orientação natural da tela.

Detectar mudanças de orientação do dispositivo (Android 10 e versões mais recentes)

A maneira mais confiável de detectar uma mudança de orientação no seu aplicativo é verificar se a função vkQueuePresentKHR() retorna VK_SUBOPTIMAL_KHR. Exemplo:

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

Observação:esta solução só funciona em dispositivos que executam Android 10 e mais recentes. Estas versões do Android retornam VK_SUBOPTIMAL_KHR de vkQueuePresentKHR(). Armazenamos o resultado faça check-in em orientationChanged, um boolean que pode ser acessado pelo aplicativos loop de renderização principal.

Detectar mudanças na orientação do dispositivo (anteriores ao Android 10)

Para dispositivos com o Android 10 ou anterior, uma implementação é necessária, porque VK_SUBOPTIMAL_KHR não é compatível.

Como usar a enquete

Em dispositivos anteriores ao Android 10, é possível pesquisar a transformação atual do dispositivo a cada Frames pollingInterval, em que pollingInterval é uma granularidade decidida pelo programador. Para fazer isso, chame vkGetPhysicalDeviceSurfaceCapabilitiesKHR() e compare o campo currentTransform retornado com o da transformação de superfície armazenada no momento (neste exemplo de código armazenado em pretransformFlag).

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

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

Em um Pixel 4 com Android 10, a pesquisa de vkGetPhysicalDeviceSurfaceCapabilitiesKHR() levou de 0,120 a 0,250 ms e em um Pixel 1XL com o Android 8, a pesquisa levou 0,110 a 0,350 ms.

Como usar callbacks

Uma segunda opção para dispositivos com execução em versões anteriores ao Android 10 é registrar um callback onNativeWindowResized() para chamar uma função que defina a sinalização orientationChanged, indicando ao aplicativo que ocorreu uma mudança na orientação:

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

Em que ResizeCallback é definido como:

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

O problema com esta solução é que onNativeWindowResized() só recebe exigia mudanças de orientação de 90 graus, como passar de paisagem para retrato ou vice-versa. Outras mudanças de orientação não vão acionar a recriação da cadeia de troca. Por exemplo, uma mudança de paisagem para paisagem invertida não a acioná-lo, exigindo que o compositor do Android faça a inversão para o seu para o aplicativo.

Como processar a mudança de orientação

Para processar a mudança de orientação, chame a rotina correspondente na parte superior do loop de renderização principal quando a variável orientationChanged estiver definida como verdadeira. Exemplo:

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

Você faz todo o trabalho necessário para recriar a cadeia de troca a função OnOrientationChange(). Isso significa que você:

  1. Destrua todas as instâncias existentes de Framebuffer e ImageView.

  2. Recriar a cadeia de troca ao destruir a cadeia de troca antiga (que será discutida a seguir) e

  3. Recrie os Framebuffers com DisplayImages da nova cadeia de troca. Observação:imagens de anexos (imagens de profundidade/estêncil, por exemplo) geralmente não precisam ser recriadas à medida que são baseados na resolução de identidade das imagens da cadeia de troca pré-rotacionadas.

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

E, no final da função, você redefine a sinalização orientationChanged como falsa para mostrar que processou a mudança de orientação.

Recreação da cadeia de troca

Na seção anterior, mencionamos ter que recriar a cadeia de troca. As primeiras etapas para fazer isso envolvem o recebimento das novas características da superfície de renderização:

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

Com a estrutura VkSurfaceCapabilities preenchida com as novas informações, agora é possível ver se ocorreu uma mudança na orientação verificando o campo currentTransform. Você a armazenará no campo pretransformFlag, porque será necessária mais tarde, quando você fizer ajustes na matriz de MVP.

Para fazer isso, especifique os seguintes atributos na estrutura 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);
}

O campo imageExtent será preenchido com a extensão displaySizeIdentity que você armazenou na inicialização do aplicativo. O campo preTransform será preenchido com a variável pretransformFlag, que é definida como o campo currentTransform de surfaceCapabilities. O campo oldSwapchain também é definido como a cadeia de troca que será destruída.

Ajuste da matriz de MVP

A última coisa que você precisa fazer é aplicar a pré-transformação aplicando uma matriz de rotação à sua matriz de MVP. Essencialmente, isso aplica a rotação no espaço de corte para que a imagem resultante seja girada para a orientação atual do dispositivo. Em seguida, você pode simplesmente transmitir essa matriz de MVP atualizada para o sombreador de vértice e usá-la normalmente sem precisar modificar os 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;

Consideração: janela de visualização de tela não cheia e tesoura

Se o aplicativo estiver usando uma região de janela de visualização/tesoura que não seja de tela cheia, ela precisará ser atualizada de acordo com a orientação do dispositivo. Isso requer que você ative as opções dinâmicas da janela de visualização e tesoura durante a criação do pipeline do 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);

O cálculo real da extensão da janela de visualização durante a gravação do buffer de comando tem esta aparência:

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

As variáveis x e y definem as coordenadas do canto superior esquerdo da janela de visualização, enquanto w e h definem a largura e a altura, respectivamente. A mesma computação também pode ser usada para definir o teste de tesoura e está incluída aqui para integridade:

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

Considerações: derivados do sombreador de fragmentos

Se o seu aplicativo estiver usando cálculos derivados, como dFdx e dFdy, outras transformações poderão ser necessárias para considerar o sistema de coordenadas rotacionado conforme esses cálculos são executados no espaço de pixels. Isso exige que o app transmita uma indicação da pré-transformação ao sombreador do fragmento, como um número inteiro que representa a orientação atual do dispositivo, e use-a para mapear corretamente os cálculos derivados:

  • Para um frame pré-girado em 90 graus
    • dFdx precisa ser mapeado para dFdy.
    • dFdy precisa ser mapeado para -dFdx.
  • Para um frame pré-girado em 270 graus
    • dFdx precisa ser mapeado para -dFdy.
    • dFdy precisa ser mapeado para dFdx.
  • Para um frame pré-girado em 180 graus,
    • dFdx precisa ser mapeado para -dFdx.
    • dFdy precisa ser mapeado para -dFdy.

Conclusão

Para que seu aplicativo aproveite ao máximo o Vulkan no Android, a implementação da pré-rotação é imprescindível. Estas são as conclusões mais importantes deste artigo:

  • Verifique se, durante a criação ou recriação da cadeia de troca, a flag de pré-transformação é Defina para corresponder à sinalização retornada pelo sistema operacional Android. Isso evitará a sobrecarga do compositor.
  • Manter o tamanho da cadeia de troca fixo na resolução de identidade da janela do app na orientação natural da tela.
  • Gire a matriz de MVP no espaço de corte para considerar a orientação do dispositivo porque a resolução/extensão da cadeia de troca não é mais atualizada com a orientação da tela.
  • Atualize retângulos de tesoura e janelas de visualização conforme a necessidade do aplicativo.

App de exemplo: pré-rotação mínima do Android (link em inglês)