通过 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 OS 您的应用会处理 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 预旋转