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

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

相較於 OpenGL,Vulkan 可讓您指定更多關於算繪狀態的資訊。伴隨著這個功能而來的是一些新的責任;您應自行實作驅動程式在 OpenGL 中明確處理的內容。其中之一是裝置方向,及其與算繪表面方向的關係。目前,Android 可以透過 3 種方式,來協調裝置的算繪表面與裝置方向:

  1. 裝置搭載一個顯示處理器 (DPU),可有效處理硬體中的表面旋轉(僅限支援的裝置)。
  2. Android OS 可透過新增合成器通道,來處理表面旋轉,這會產生效能成本,具體取決於合成器必須如何處理輸出圖像旋轉。
  3. 應用程式本身可將旋轉的圖像算繪到與螢幕目前方向相符的算繪表面,從而處理表面旋轉。

這對您的應用程式來說有何意義?

目前,應用程式無法得知在應用程式外進行表面旋轉是否無須付費。即使會有 DPU 為您代勞,系統可能還是會有可評估成效的懲處。如果您的應用程式受到 CPU 限制,這會是由 Android 編譯器 (通常以更高的頻率執行) 增加的 GPU 用量而造成的電力問題。如果您的應用程式受到 GPU 限制,Android 編譯器也可能會預先佔用應用程式的 GPU 工作,造成額外的效能損失。

在 Pixel 4 XL 上執行標題運送時,我們發現 SurfaceFlinger (驅動 Android 編譯器的優先順序較高的工作) 會定期佔用應用程式的工作,導致 1 至 3 毫秒就達到影格時間。此外,由於合成器必須讀取整個影格緩衝區來完成合成工作,因此對 GPU 的頂點/材質記憶體造成了更大的壓力。

正確處理方向幾乎完全停止了 SurfaceFlinger 的 GPU 佔用機制,但由於不再需要 Android 合成器使用的較高頻率,GPU 頻率下降了 40%。

為確保我們能正確處理表面旋轉,並盡量減少負擔 (如上例所示),建議您實作方法 3 (又稱為預先旋轉)。這會告知 Android OS 您的應用程式能處理表面旋轉。方法很簡單,只要傳遞表面轉換旗標,即可在建立交換鏈期間指定方向。這會阻止 Android 編譯器自己執行旋轉作業。

暸解如何設定表面轉換旗標對於每個 Vulkan 應用程式都很重要,因為應用程式通常支援多個方向或支援一個方向,其中算繪表面的方向與裝置認為的身分方向不同。例如,在直向身分手機上使用僅橫向應用程式,或是在橫向身分平板電腦上使用僅直向應用程式。

本文將詳細說明如何在 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 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() 函式中,您將完成重新建立交換鏈作業所需的一切作業。此作業會刪除 FramebufferImageView 的所有現有執行個體;重建交換鏈,同時刪除舊交換鏈 (後續將進行說明);然後使用新的交換鏈 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 預旋轉設定