本文介绍了如何通过实现预旋转来高效处理 Vulkan 应用中的设备旋转。
借助 Vulkan,您可以指定比使用 OpenGL 多得多的渲染状态相关信息。使用 Vulkan 时,您必须显式实现由 OpenGL 中的驱动程序处理的内容,例如设备方向及其与渲染 Surface 方向的关系。Android 可以通过以下三种方式处理设备的呈现 Surface 与设备屏幕方向之间的协调问题:
- Android OS 可以使用设备的显示处理器 (DPU),它可以高效处理硬件中的 Surface 旋转。仅适用于受支持的设备。
- Android 操作系统可以通过添加合成器传递来处理 Surface 旋转。这会降低性能,具体取决于合成器必须如何处理输出图像的旋转。
- 应用本身可以通过将旋转后的图片渲染到与显示屏的当前方向相匹配的呈现 Surface 上,来处理 Surface 旋转。
您应该使用以下哪种方法?
目前,应用还无法知道在应用外部处理 Surface 旋转是否会是免费的。即使有 DPU 会为您处理 Surface 旋转,仍然可能会使性能大幅降低。如果您的应用受 CPU 限制,那么 Android 合成器通常会以更高的频率运行,会增加对 GPU 的使用量,因此这会造成功耗问题。如果您的应用受到 GPU 的限制,Android 合成器也会抢占应用的 GPU 工作,导致额外的性能损失。
在 Pixel 4XL 上运行搭载游戏时,我们看到 SurfaceFlinger(驱动 Android 合成器的优先级较高的任务):
定期抢占应用工作,导致帧时间发生 1-3 毫秒的命中;以及
这会对 GPU 的顶点/纹理内存造成更大的压力,因为合成器必须读取整个帧缓冲区才能完成合成工作。
以适当方式处理屏幕方向几乎能够完全阻止 SurfaceFlinger 抢占 GPU,并且 GPU 频率会下降 40%,因为 Android 合成器不再需要以加快的频率运行。
为确保以尽可能低的开销正确处理表面旋转(如上例所示),您应实现方法 3。这称为预旋转。这会告知 Android 操作系统您的应用会处理 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()
函数中执行重新创建交换链所需的所有工作。这意味着您:
销毁
Framebuffer
和ImageView
的所有现有实例,在销毁旧的交换链时重新创建交换链(稍后将讨论),并且
使用新交换链的 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);
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 矩阵以考虑设备的屏幕方向,因为交换链分辨率/范围不再随屏幕的方向更新。
- 根据应用的需要更新视口和剪刀矩形。