本文介绍了如何通过实现预旋转来高效处理 Vulkan 应用中的设备旋转。
与 OpenGL 相比,使用 Vulkan,您可以指定更多有关呈现状态的信息。使用 Vulkan 时,您必须明确实现在 OpenGL 中由驱动程序处理的内容,例如设备屏幕方向及其与呈现 Surface 屏幕方向的关系。Android 可通过 3 种方式处理设备的呈现 Surface 与设备的屏幕方向之间的协调问题:
- Android OS 可以使用设备的显示处理单元 (DPU),该单元可有效地处理硬件中的 Surface 旋转。仅适用于受支持的设备。
- Android OS 可以通过添加合成器通道来处理 Surface 旋转。这将会降低性能,具体取决于合成器需要如何处理输出图像旋转。
- 应用本身可以处理 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 操作系统您的应用将会处理 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()
函数中完成重新创建交换链所需的所有工作。这意味着您:
销毁
Framebuffer
和ImageView
的所有现有实例,重新创建交换链,同时销毁原有交换链(将在接下来的部分中进行讨论);
使用新交换链的 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);
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 矩阵,以将设备屏幕方向纳入考虑。
- 根据应用的需要更新视口和剪刀矩形。