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

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

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

  1. Android OS 可以使用设备的显示处理器 (DPU),它可以高效处理硬件中的 Surface 旋转。仅适用于受支持的设备。
  2. Android 操作系统可以通过添加合成器卡券来处理 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 重新创建帧缓冲区。注意:附件图片(例如深度/模板图片)通常不需要重新创建,因为它们基于预旋转的交换链图片的身份分辨率。

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 预旋转