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

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

使用 Vulkan,您可以指定比 OpenGL 更多的渲染状态信息。使用 Vulkan,您必须显式实现 OpenGL 中由驱动程序处理的事项,例如设备方向及其与渲染表面方向的关系。Android 可以通过三种方式协调设备的渲染表面与设备方向

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

您应该使用哪种方法?

目前,应用程序无法知道在应用程序外部处理的表面旋转是否免费。即使有 DPU 为您处理此问题,仍然可能会有可衡量的性能损失。如果您的应用程序受 CPU 限制,由于 Android Compositor 的 GPU 使用率增加(通常以提升的频率运行),这会成为功耗问题。如果您的应用程序受 GPU 限制,则 Android Compositor 也可能抢占您应用程序的 GPU 工作,导致额外的性能损失。

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

  • 经常抢占应用程序的工作,导致帧时间增加 1-3 毫秒,并且

  • 增加 GPU 顶点/纹理内存的压力,因为 Compositor 必须读取整个帧缓冲区才能执行其合成工作。

正确处理方向几乎完全阻止了 SurfaceFlinger 的 GPU 抢占,同时 GPU 频率下降了 40%,因为不再需要 Android Compositor 使用的提升频率。

为了确保表面旋转以尽可能小的开销正确处理(如前述情况所示),您应该实施方法 3。这被称为预旋转。这告诉 Android 操作系统您的应用处理表面旋转。您可以通过在交换链创建期间传递指定方向的表面变换标志来做到这一点。这会阻止 Android Compositor 自行执行旋转。

了解如何设置表面变换标志对于每个 Vulkan 应用程序都很重要。应用程序通常要么支持多种方向,要么支持渲染表面与设备认为的其身份方向不同的单一方向。例如,肖像身份手机上的仅横向应用程序,或横向身份平板电脑上的仅肖像应用程序。

修改 AndroidManifest.xml

要在您的应用中处理设备旋转,首先更改应用程序的 AndroidManifest.xml 文件,告知 Android 您的应用将处理方向和屏幕尺寸变化。这可以防止 Android 在方向更改时销毁并重新创建 Android Activity 并调用现有窗口表面上的 onDestroy() 函数。这是通过向 activity 的 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 变量(该变量设置为 surfaceCapabilities 的 currentTransform 字段)。您还将 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 预旋转