Vulkan の事前回転でデバイスの向きを処理する

この記事では、事前回転を実装して、Vulkan アプリでデバイスの回転を効率的に処理する方法について説明します。

Vulkan では、OpenGL よりもレンダリング状態に関する情報をより多く指定できます。Vulkan では、OpenGL でドライバによって処理されるもの(デバイスの向きや、レンダリング サーフェスの向きとの関係など)を明示的に実装する必要があります。Android でデバイスのレンダリング サーフェスをデバイスの向きに合わせる方法は 3 つあります。

  1. Android OS は、デバイスのディスプレイ処理ユニット(DPU)を使用できます。DPU は、ハードウェアでサーフェスの回転を効率的に処理できます。サポートされているデバイスでのみ利用できます。
  2. Android OS は、コンポジタ パスを追加することで、サーフェスの回転を処理できます。これは、コンポジタが出力画像の回転をどのように処理する必要があるかに応じて、パフォーマンスに影響します。
  3. アプリケーション自体は、ディスプレイの現在の向きに一致するレンダリング サーフェスに回転した画像をレンダリングすることで、サーフェスの回転を処理できます。

次のうち、どの方法を使用すべきですか。

現在、アプリケーションの外部で処理されるサーフェスの回転が無料になるかどうかをアプリケーションが知る方法はありません。アプリに対してそのような処理を行う DPU があるとしても、測定可能な量のパフォーマンス コストが依然として存在する可能性があります。アプリケーションが CPU バウンドの場合、Android コンポジタによる GPU 使用率の増加により、電力の問題が発生します。通常、Android コンポジタはブーストされた周波数で動作します。アプリが GPU バウンドである場合は、Android コンポジターがアプリの GPU 処理のプリエンプトも行うので、さらにパフォーマンスが低下することがあります。

Google Pixel 4XL で出荷タイトルを実行すると、SurfaceFlinger(Android Compositor を駆動する優先度の高いタスク)が次のようになることが確認されています。

  • アプリの処理を定期的にプリエンプトし、フレーム時間に 1 ~ 3 ミリ秒の影響を与えます。

  • コンポジタが合成作業を行うためにフレームバッファ全体を読み取る必要があるため、GPU の頂点/テクスチャ メモリに大きな負荷がかかります。

向きを適切に処理すると、SurfaceFlinger による GPU のプリエンプションがほぼ完全に停止する一方で、GPU 周波数は 40% 低下します。これは、Android コンポジターが使用するブースト周波数が不要になるためです。

前述のケースのように、オーバーヘッドをできるだけ少なくしてサーフェスの回転を適切に処理するには、方法 3 を実装する必要があります。これは事前回転と呼ばれます。これにより、アプリがサーフェスの回転を処理することを Android OS に通知します。これを行うには、スワップチェーンの作成時に、向きを指定するサーフェス変換フラグを渡します。これにより、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 属性を使用して画面の向きを固定している場合は、この操作を行う必要はありません。また、アプリケーションで固定の向きを使用する場合は、アプリケーションの起動時または再開時にスワップチェーンを 1 回設定するだけで済みます。

本来の画面解像度とカメラ パラメータを取得する

次に、VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR 値に関連付けられたデバイスの画面解像度を検出します。この解像度はデバイスの本来の向きと関連付けられているため、スワップチェーンを常にこの解像度に設定する必要があります。これを取得する最も信頼性の高い方法は、アプリケーションの起動時に vkGetPhysicalDeviceSurfaceCapabilitiesKHR() を呼び出し、返されたエクステントを保存することです。また、currentTransform に基づいて幅と高さを入れ替えて、ID 画面の解像度を保存します。

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 が返されます。このチェックの結果は、アプリケーションのメイン レンダリング ループからアクセス可能な boolean である orientationChanged に保存されます。

デバイスの向きの変更を検出する(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. Framebuffer と ImageView の既存のインスタンスを破棄します。

  2. 古いスワップチェーンを破棄しながらスワップチェーンを再作成する(これについては後述します)。

  3. 新しいスワップチェーンの DisplayImage を使用して Framebuffer を再作成します。注: アタッチメント画像(深度/ステンシル画像など)は、通常、回転前のスワップチェーン画像の ID 解決に基づいているため、再作成する必要はありません。

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 フィールドをチェックすることにより、向きの変更が発生したかどうかを確認できます。この情報は、後で MVP マトリックスの調整を行うときに必要になるため、pretransformFlag フィールドに保存します。

これを行うには、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);

検討事項 - フラグメント シェーダーのデリバティブ

アプリが dFdx や dFdy などのデリバティブ計算を使用している場合、これらの計算はピクセル空間で実行されるので、回転された座標系を考慮して追加の変換が必要になることがあります。そのため、アプリは preTransform を示す値をフラグメント シェーダーに渡し(たとえば現在のデバイスの向きを表す整数)、それを使用してデリバティブ計算を適切にマッピングする必要があります。

  • 90 度で事前回転されたフレームの場合
    • dFdx は dFdy にマッピングする必要があります。
    • dFdy は -dFdx にマッピングする必要があります。
  • 270 度で事前回転されたフレームの場合
    • dFdx は -dFdy にマッピングする必要があります。
    • dFdy は dFdx にマッピングする必要があります。
  • 180 度で事前回転されたフレームの場合
    • dFdx は -dFdx にマッピングする必要があります。
    • dFdy は -dFdy にマッピングする必要があります。

まとめ

アプリが Android で Vulkan を最大限に活用するためには、事前回転を実装することが必要です。この記事の最も重要な結論は次のとおりです。

  • スワップチェーンの作成または再作成時に、Android オペレーティング システムから返されるフラグと一致するように pretransform フラグが設定されていることを確認します。これにより、コンポジタのオーバーヘッドを回避できます。
  • スワップチェーンのサイズを、ディスプレイの自然な向きにおけるアプリのウィンドウ サーフェスのアイデンティティ解像度に固定します。
  • スワップチェーンの解像度/範囲がディスプレイの向きに応じて更新されなくなるため、デバイスの向きを考慮してクリップ空間で MVP 行列を回転させます。
  • アプリケーションの必要に応じて、ビューポートとシザーの長方形を更新します。

サンプルアプリ: 最小限の Android の事前回転