使用 Vulkan 预旋转处理设备方向

本文介绍了如何通过实现预旋转在你的 Vulkan 应用程序中有效地处理设备旋转。

使用 Vulkan,你可以指定比 OpenGL 中更多的渲染状态信息。使用 Vulkan,你必须明确地实现由驱动程序在 OpenGL 中处理的事情,例如 **设备方向** 及其与 **渲染表面方向** 的关系。Android 处理设备渲染表面与设备方向协调有三种方法。

  1. Android 操作系统可以使用设备的显示处理单元 (DPU),它可以在硬件中有效地处理表面旋转。仅适用于支持的设备。
  2. Android 操作系统可以通过添加一个合成器通道来处理表面旋转。这将产生性能成本,具体取决于合成器如何处理旋转输出图像。
  3. 应用程序本身可以通过将旋转后的图像渲染到与当前显示方向匹配的渲染表面来处理表面旋转。

你应该使用哪种方法?

目前,应用程序无法知道应用程序外部处理的表面旋转是否免费。即使有一个 DPU 来为你处理此事,仍然可能会有明显的性能损失。如果你的应用程序是 CPU 密集型的,由于 Android 合成器使用的 GPU 使用率增加(通常以更高的频率运行),这将成为一个电源问题。如果你的应用程序是 GPU 密集型的,那么 Android 合成器也可以抢占你的应用程序的 GPU 工作,导致额外的性能损失。

在 Pixel 4XL 上运行发布版本时,我们发现 SurfaceFlinger(驱动 Android 合成器的优先级更高的任务)

  • 定期抢占应用程序的工作,导致帧时间出现 1-3 毫秒的命中,并且

  • 对 GPU 的顶点/纹理内存施加更大的压力,因为合成器必须读取整个帧缓冲区才能完成其合成工作。

正确处理方向几乎完全阻止了 SurfaceFlinger 对 GPU 的抢占,而 GPU 频率下降了 40%,因为不再需要 Android 合成器使用的增强频率。

为了确保表面旋转以尽可能少的开销正确处理,如前例所示,你应该实现方法 3。这被称为 **预旋转**。这会告诉 Android 操作系统 **你的应用程序** 处理表面旋转。你可以通过在交换链创建期间传递指定方向的表面变换标志来做到这一点。这会 *阻止* Android 合成器 *自己* 进行旋转。

了解如何设置表面变换标志对于每个 Vulkan 应用程序都很重要。应用程序倾向于支持多种方向或支持一种方向,其中渲染表面与设备认为的其标识方向不同。例如,在纵向标识手机上支持横向方向的应用程序,或在横向标识平板电脑上支持纵向方向的应用程序。

修改 AndroidManifest.xml

要处理应用程序中的设备旋转,首先要更改应用程序的 AndroidManifest.xml 文件,告诉 Android 你的应用程序将处理方向和屏幕尺寸更改。这会阻止 Android 在发生方向更改时销毁和重新创建 Android Activity,以及在现有窗口表面上调用 onDestroy() 函数。这可以通过向活动 configChanges 部分添加 orientation(支持 API 等级 <13)和 screenSize 属性来完成。

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

如果你的应用程序使用 screenOrientation 属性来固定其屏幕方向,则无需执行此操作。此外,如果你的应用程序使用固定方向,则它只需要在应用程序启动/恢复时设置一次交换链。

获取标识屏幕分辨率和相机参数

接下来,检测与 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 从 vkQueuePresentKHR() 返回 VK_SUBOPTIMAL_KHR。我们将此检查的结果存储在 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;
}

在运行 Android 10 的 Pixel 4 上,轮询 vkGetPhysicalDeviceSurfaceCapabilitiesKHR() 耗时在 0.120-0.250 毫秒之间,而在运行 Android 8 的 Pixel 1XL 上,轮询耗时在 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. 销毁任何现有的FramebufferImageView实例,

  2. 在销毁旧交换链的同时重建交换链(将在下一节讨论),以及

  3. 使用新的交换链的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,以表明您已经处理了方向变化。

交换链重建

在上一节中,我们提到了需要重建交换链。第一步是获取渲染表面的新特征

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变量(设置为surfaceCapabilitiescurrentTransform字段)填充。 您还需要将oldSwapchain字段设置为将要销毁的交换链。

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;

注意事项 - 非全屏视口和剪切

如果您的应用程序使用的是非全屏视口/剪切区域,则需要根据设备的方向更新它们。 这要求您在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);

xy变量定义视口左上角的坐标,而wh分别定义视口的宽度和高度。 同样的计算也可以用来设置剪切测试,这里为了完整性而包含。

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

注意事项 - 片段着色器导数

如果您的应用程序使用的是导数计算(例如dFdxdFdy),则可能需要额外的变换来处理旋转的坐标系,因为这些计算是在像素空间中执行的。 这要求应用程序将预变换的某些指示传递给片段着色器(例如,表示当前设备方向的整数),并使用它来正确映射导数计算

  • 对于90度预旋转帧
    • dFdx必须映射到dFdy
    • dFdy必须映射到-dFdx
  • 对于270度预旋转帧
    • dFdx必须映射到-dFdy
    • dFdy必须映射到dFdx
  • 对于180度预旋转帧,
    • dFdx必须映射到-dFdx
    • dFdy必须映射到-dFdy

结论

为了让您的应用程序充分利用Android上的Vulkan,实现预旋转是必须的。 本文最重要的收获是

  • 确保在交换链创建或重新创建期间,预变换标志设置为与Android操作系统返回的标志匹配。 这将避免合成器开销。
  • 将交换链大小固定为应用程序窗口表面在显示器自然方向上的标识分辨率。
  • 在裁剪空间中旋转MVP矩阵以处理设备方向,因为交换链分辨率/范围不再随显示器方向更新。
  • 根据您的应用程序需要更新视口和剪切矩形。

示例应用程序:最小Android预旋转