通过 Vulkan 预旋转处理设备的屏幕方向

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

与 OpenGL 相比,使用 Vulkan,您可以指定更多有关呈现状态的信息。使用 Vulkan 时,您必须明确实现在 OpenGL 中由驱动程序处理的内容,例如设备屏幕方向及其与呈现 Surface 屏幕方向的关系。Android 可通过 3 种方式处理设备的呈现 Surface 与设备的屏幕方向之间的协调问题:

  1. Android OS 可以使用设备的显示处理单元 (DPU),该单元可有效地处理硬件中的 Surface 旋转。仅适用于受支持的设备。
  2. Android OS 可以通过添加合成器通道来处理 Surface 旋转。这将会降低性能,具体取决于合成器需要如何处理输出图像旋转。
  3. 应用本身可以处理 Surface 旋转,具体方法是:将旋转的图片呈现到与屏幕的当前方向一致的呈现 Surface。

您应该使用以下哪种方法?

目前,应用无法知道在应用外部处理 Surface 旋转是否会降低性能。即使有 DPU 会为您处理 Surface 旋转,仍然可能会使性能大幅降低。如果您的应用受到 CPU 的限制,这样可能会因为 Android 合成器(通常会以加快的频率运行)对 GPU 的使用增加而造成功耗问题。如果您的应用受到 GPU 的限制,Android 合成器也会抢占应用的 GPU 工作,导致额外的性能损失。

在 Pixel 4XL 上运行发行版本影视内容时,我们发现 SurfaceFlinger(驱动 Android 合成器的优先级较高的任务):

  • 定期抢占应用的工作,导致帧时间出现 1-3 毫秒的延迟,

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

以适当方式处理屏幕方向几乎能够完全阻止 SurfaceFlinger 抢占 GPU,并且 GPU 频率会下降 40%,因为 Android 合成器不再需要以加快的频率运行。

为了确保以尽可能低的开销适当处理 Surface 旋转,如上例所示,您应实现方法 3。这称为预旋转。这会告知 Android 操作系统您的应用将会处理 Surface 旋转。为此,您可以通过在交换链创建期间传递指定屏幕方向的 Surface 转换标志。这会阻止 Android 合成器自行处理旋转。

对于每个 Vulkan 应用来说,了解如何设置 Surface 转换标志至关重要。应用往往支持多个屏幕方向,或者支持一个屏幕方向,但呈现 Surface 的屏幕方向与设备认为的其自身屏幕方向不一致。竖版手机上的只支持横向显示的应用,或在横版平板电脑上的只支持纵向显示的应用。

修改 AndroidManifest.xml

如需在应用中处理设备旋转,请先更改应用的 AndroidManifest.xml 文件,以告知 Android 您的应用将处理屏幕方向和屏幕尺寸变化。这可以防止 Android 在屏幕方向发生变化时执行以下操作:销毁并重新创建 Android Activity,并对现有窗口Surface 调用 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 结构,我们用它来存储应用窗口 Surface 在屏幕自然方向的上述自身分辨率。

检测设备屏幕方向变化(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 字段与当前存储的 Surface 转换(在此代码示例中,存储在 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() 需要 .120-.250 毫秒,在搭载 Android 8 的 Pixel 1XL 上,轮询需要 .110-.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;
}

此解决方案的问题在于,只有在屏幕方向发生 90 度变化(例如从横向变为纵向或反之)时,系统才会调用 onNativeWindowResized()。其他屏幕方向更改不会触发交换链重新创建。 例如,从横向模式更改为反向横向模式不会触发此操作,而需要 Android 合成器为应用执行翻转。

处理屏幕方向变化

如要处理屏幕方向变化,请在 orientationChanged 变量设置为 true 时,在主渲染循环的顶部调用屏幕方向变化例程。例如:

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

您需要在 OnOrientationChange() 函数中完成重新创建交换链所需的所有工作。这意味着您:

  1. 销毁 FramebufferImageView 的所有现有实例,

  2. 重新创建交换链,同时销毁原有交换链(将在接下来的部分中进行讨论);

  3. 使用新交换链的 DisplayImages 重新创建 Framebuffer。注意:附件图片(例如深度/模板图片)通常无需重新创建,因为它们基于预旋转的交换链图片的自身屏幕分辨率。

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,以表示您已处理屏幕方向变化。

重新创建交换链

在上一部分中,我们提到需要重新创建交换链。如要实现该目标,第一步需要获取呈现 Surface 的新特征:

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

注意事项 - fragment 着色器导数

如果您的应用使用的是导数计算(例如 dFdxdFdy),则可能需要进行其他转换才能将旋转后的坐标系统纳入考虑,因为这些计算是在像素空间中执行的。为此,需要应用将 preTransform 的某些指示元素(例如表示当前设备屏幕方向的整数)传递到 fragment 着色器中,并使用相应指示元素来适当映射导数计算:

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

总结

为了使应用在 Android 上充分利用 Vulkan,必须实现预旋转。本文中最重要的内容如下:

  • 确保在交换链创建或重新创建期间设置预转换标志,以便它与 Android 操作系统返回的标志一致。这样可避免产生合成器开销。
  • 确保将交换链的大小固定为应用窗口 Surface 在屏幕的自然屏幕方向下的自身屏幕分辨率。
  • 由于交换链的分辨率/范围不再根据屏幕的方向进行更新,因此请在裁剪空间内旋转 MVP 矩阵,以将设备屏幕方向纳入考虑。
  • 根据应用的需要更新视口和剪刀矩形。

示例应用:最低限度的 Android 预旋转