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

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

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

  1. Android 操作系统可以使用设备的显示处理器 (DPU), 它可以高效地处理硬件中的 Surface 旋转。播放设备 受支持的设备。
  2. Android OS 可以通过添加合成器通道来处理 Surface 旋转。这个 会产生性能成本,具体取决于合成器处理 旋转输出图像。
  3. 应用本身可以通过渲染 将图片旋转至与 显示屏上。

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

目前,应用还不知道表面是否旋转 也完全免费即使有 DPU 会为您处理 Surface 旋转,仍然可能会使性能大幅降低。如果您的应用受 CPU 限制,这会造成电源问题,因为 Android 合成器(通常以 频率已提升。如果您的应用受到 GPU 的限制,Android 合成器也会抢占应用的 GPU 工作,导致额外的性能损失。

我们发现,在 Pixel 4XL 上推出推出后的应用或游戏时,我们发现 SurfaceFlinger(促成 Android 运行线程的 合成器):

  • 定期抢占应用的工作,占用 1-3 毫秒的时间 帧时间命中次数

  • 给 GPU 的 因为合成器必须读取整个 帧缓冲区来完成合成工作。

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

为了确保表面旋转得到正确处理,并且开销 如前面的案例所示,您应该实现方法 3。 这称为“预旋转”。这会告知 Android OS 您的应用 负责处理表面旋转。为此,您可以通过在交换链创建期间传递指定屏幕方向的 Surface 转换标志。这会停止 Android 合成器执行旋转操作。

对于每个 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 之前版本的设备上,您可以每隔 1 次轮询一次当前设备转换 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;
}

此解决方案的问题是,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,以表示您已处理屏幕方向变化。

重新创建交换链

在上一部分中,我们提到需要重新创建交换链。如要实现该目标,第一步需要获取呈现 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 操作系统返回的标记相匹配。这样可以避免 合成器开销
  • 将交换链大小固定为应用窗口的身份分辨率 自然屏幕方向的表面。
  • 在裁剪空间中旋转 MVP 矩阵,以考虑设备屏幕方向, 因为交换链分辨率/范围不再随方向更新, 。
  • 根据应用的需要更新视口和剪刀矩形。

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