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

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

与 OpenGL 相比,Vulkan 可让您指定更多有关渲染状态的信息。使用 Vulkan 时,您必须明确实现由 OpenGL 中的驱动程序处理的内容,例如设备屏幕方向及其与渲染 surface 方向的关系。Android 可以通过三种方式来处理设备渲染 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 合成器自行执行旋转。

了解如何设置 Surface 转换标志对于每个 Vulkan 应用都非常重要。应用往往支持多个屏幕方向,或者支持单个屏幕方向,其中渲染 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. 销毁 Framebuffer 和 ImageView 的所有现有实例,

  2. 重新创建交换链,同时销毁旧交换链(将在下文讨论),以及

  3. 使用新交换链的 DisplayImage 重新创建 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);

x 和 y 变量用于定义视口左上角的坐标,而 w 和 h 分别用于定义视口的宽度和高度。同样的计算也可用于设置剪裁测试,此处包含该计算是为了确保完整性:

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 着色器导数

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

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

总结

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

  • 确保在创建或重新创建交换链期间,将预转换标志设置为与 Android 操作系统返回的标志一致。这样可以避免合成器开销。
  • 在显示屏的自然屏幕方向上,使交换链大小固定为应用窗口 Surface 的标识分辨率。
  • 在裁剪空间中旋转 MVP 矩阵以考虑设备屏幕方向,因为交换链分辨率/范围不再随显示屏方向而更新。
  • 根据应用需要更新视口和裁剪矩形。

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