使用 Vulkan 預旋轉功能處理裝置方向

本文說明如何實作預旋轉功能,有效處理 Vulkan 應用程式中的裝置旋轉作業。

相較於 OpenGL,Vulkan 可讓您指定更多算繪狀態的資訊。使用 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 先占行為,但由於 Android 合成器不必再使用較高頻率,GPU 頻率會下降 40%。

如要確保能正確處理介面旋轉作業,並盡量減少負擔 (如上述案例所示),您應實作方法 3,也就是預旋轉機制。這項機制會向 Android 作業系統告知您的應用程式可處理介面旋轉作業。實作方法很簡單,只要在交換鏈建立期間,傳遞用來指定方向的介面轉換旗標即可。這會阻止 Android 合成器本身執行旋轉作業。

請務必針對每個 Vulkan 應用程式,暸解如何設定介面轉換旗標。一般而言,應用程式會支援多個螢幕方向,或是僅支援單一螢幕方向,且其中算繪介面的方向不同於裝置認定的身分方向,例如應用程式在直向身分的手機上僅支援橫向模式,或在橫向身分的平板電腦上僅支援直向模式。

修改 AndroidManifest.xml

如要處理應用程式中的裝置旋轉,請先變更應用程式的 AndroidManifest.xml 檔案,告知 Android 應用程式會處理方向和螢幕大小變更。如此一來,當螢幕方向變更時,Android 就不會刪除並重建 Android Activity,也不會在現有視窗介面上呼叫 onDestroy() 函式。實作方法是將 orientation (支援 API 13 以下級別) 和 screenSize 屬性,新增至活動的 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;
}

這個解決方案的問題是,只有在發生 90 度方向變更時才會呼叫 onNativeWindowResized(),例如從橫向變更為直向,或從直向變更為橫向時。其他方向變更並不會觸發交換鏈重建作業。舉例來說,如果是從橫向變更為反向橫向,並不會觸發這項作業,也不會要求 Android 合成器為應用程式執行旋轉作業。

處理方向變更

如要處理方向變更,請在 orientationChanged 變數設為 true 時,呼叫主要算繪迴圈頂端的方向變更處理常式。例如:

bool VulkanDrawFrame() {
 if (orientationChanged) {
   OnOrientationChange();
}

您將在 OnOrientationChange() 函式中,執行重建交換鏈所需的一切工作,包括:

  1. 刪除任何現有的 FramebufferImageView 例項。

  2. 重建交換鏈,同時刪除舊交換鏈 (稍後將會說明)。

  3. 使用新交換鏈的 DisplayImage 重建影格緩衝區。注意:深度/模板圖像等附件圖像通常不必重建,因為這些圖像是根據預旋轉交換鏈圖像的身分解析度建立的。

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),則可能會進行額外的轉換來考量旋轉座標系統,因為這些計算是在像素空間內執行。應用程式必須將其 preTransform 的部分指標傳送至片段著色器 (例如代表目前裝置方向的整數),然後以正確方式對應衍生式計算:

  • 若是 90 度的預旋轉畫面
    • dFdx 必須對應至 dFdy
    • dFdy 必須對應至 -dFdx
  • 若是 270 度的預旋轉畫面
    • dFdx 必須對應至 -dFdy
    • dFdy 必須對應至 dFdx
  • 若是 180 度的預旋轉畫面
    • dFdx 必須對應至 -dFdx
    • dFdy 必須對應至 -dFdy

結語

您必須讓應用程式採用預旋轉功能,才能在 Android 上充分發揮 Vulkan 的效益。本文重點如下:

  • 請確保在交換鏈建立或重建期間,pretransform 旗標已設為與 Android 作業系統傳回的旗標相符,避免造成合成器的負擔。
  • 在螢幕的自然方向上,將交換鏈大小固定為應用程式視窗介面的身分解析度。
  • 旋轉裁減空間中的 MVP 矩陣,將裝置方向納入考量,因為交換鏈解析度/範圍不會再隨著螢幕方向更新。
  • 根據應用程式的需要,更新可視區域和剪刀矩形。

範例應用程式:最低 Android 預旋轉設定