使用Vulkan预旋转处理设备方向

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

使用Vulkan,您可以指定比OpenGL中更多的渲染状态信息。使用Vulkan,您必须明确地实现由OpenGL中的驱动程序处理的内容,例如**设备方向**及其与**渲染表面方向**的关系。Android可以通过三种方式处理将设备的渲染表面与设备方向协调一致。

  1. Android操作系统可以使用设备的显示处理单元 (DPU),它可以高效地处理硬件中的表面旋转。仅在支持的设备上可用。
  2. Android操作系统可以通过添加合成器通道来处理表面旋转。这将产生性能损失,具体取决于合成器如何处理旋转输出图像。
  3. 应用程序本身可以通过将旋转后的图像渲染到与显示屏当前方向匹配的渲染表面上来处理表面旋转。

您应该使用哪种方法?

目前,应用程序无法知道应用程序外部处理的表面旋转是否免费。即使有DPU为您处理此问题,也可能仍然会产生可衡量的性能损失。如果您的应用程序受CPU限制,则由于Android合成器(通常以提升的频率运行)的GPU使用率增加,这会成为一个功耗问题。如果您的应用程序受GPU限制,则Android合成器也可以抢占应用程序的GPU工作,从而导致额外的性能损失。

在Pixel 4XL上运行发行版游戏时,我们发现SurfaceFlinger(驱动Android合成器的更高优先级任务)

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

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

正确处理方向几乎完全停止了SurfaceFlinger对GPU的抢占,而GPU频率下降了40%,因为不再需要Android合成器使用的提升频率。

为了确保表面旋转以尽可能少的开销正确处理,如前例所示,您应该实现方法3。这称为**预旋转**。这告诉Android操作系统**您的应用程序**处理表面旋转。您可以通过在交换链创建期间传递指定方向的表面转换标志来实现此目的。这会_阻止_Android合成器_自己_进行旋转。

了解如何设置表面转换标志对于每个Vulkan应用程序都很重要。应用程序往往要么支持多种方向,要么支持单一方向,其中渲染表面与设备认为的其标识方向不同。例如,在纵向标识手机上的横向专用应用程序,或在横向标识平板电脑上的纵向专用应用程序。

修改AndroidManifest.xml

要在您的应用中处理设备旋转,首先更改应用程序的AndroidManifest.xml文件,以告诉Android您的应用程序将处理方向和屏幕尺寸更改。这可以防止Android在发生方向更改时销毁和重新创建Android Activity 并在现有窗口表面上调用onDestroy()函数。这是通过将orientation(以支持API级别<13)和screenSize属性添加到activity的configChanges部分来完成的。

<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 结构体,我们用它来存储应用窗口表面在显示器原生方向上的分辨率。

检测设备方向变化 (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 字段与当前存储的表面变换(在此代码示例中存储在 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() 耗时在 0.120-0.250 毫秒之间;在运行 Android 8 的 Pixel 1XL 上,轮询耗时在 0.110-0.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 重新创建 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,以表明你已处理方向变化。

交换链重新创建

在上一节中,我们提到需要重新创建交换链。第一步是获取渲染表面的新特性:

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

考虑因素 - 片段着色器导数

如果你的应用程序使用导数计算(例如 dFdxdFdy),则可能需要额外的变换来解释旋转的坐标系,因为这些计算是在像素空间中执行的。这需要应用程序将预变换的一些指示传递到片段着色器(例如表示当前设备方向的整数),并使用它来正确地映射导数计算。

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

结论

为了让你的应用程序充分利用 Android 上的 Vulkan,实现预旋转是必须的。本文最重要的要点是:

  • 确保在交换链创建或重新创建期间,预变换标志设置为与 Android 操作系统返回的标志匹配。这将避免合成器开销。
  • 将交换链大小固定为应用窗口表面在显示器原生方向上的标识分辨率。
  • 在裁剪空间中旋转 MVP 矩阵以解释设备方向,因为交换链分辨率/范围不再随显示方向更新。
  • 根据你的应用程序需要更新视口和剪切矩形。

示例应用: 最小的 Android 预旋转