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

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

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

  1. Android OS は、ハードウェアでサーフェスの回転を効率的に処理できるデバイスのディスプレイ処理ユニット(DPU)を使用できます。サポートされているデバイスでのみ利用できます。
  2. Android OS は、コンポジター パスを追加することにより、サーフェスの回転を処理できます。コンポジターが出力画像の回転を取り扱う方法によって、パフォーマンス コストが変化します。
  3. アプリ側では、回転された画像を、その時点のディスプレイの向きと一致するレンダリング サーフェスにレンダリングすることで、サーフェスの回転を処理します。

どの方法を使用すればよいですか。

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

Google Pixel 4XL で出荷時のタイトルを実行すると、SurfaceFlinger(Android コンポーザタを駆動する優先度の高いタスク)が次のように動作します。

  • アプリケーションの処理を定期的にプリエンプトし、フレームタイムに 1 ~ 3 ms のヒットが発生する。

  • コンポジタはコンポジション処理を行うためにフレームバッファ全体を読み取る必要があるため、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 属性を使用して画面の向きを固定している場合は、これを行う必要はありません。また、アプリが固定された向きを使用している場合は、アプリの起動/再開時にスワップチェーンを一度セットアップするだけで済みます。

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

次に、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;
}

このソリューションの問題は、onNativeWindowResized() が呼び出されるのは、横向きから縦向きへの切り替えなど、向きの変更が 90 度の場合のみである点です。その他の向きの変更では、スワップチェーンの再作成はトリガーされません。たとえば、横向きから縦向きへの変更ではトリガーされません。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 フィールドをチェックすることにより、向きの変更が発生したかどうかを確認できます。この情報は、後で 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 変数はビューポートの左上隅の座標を定義し、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 度で事前回転されたフレームの場合
    • dFdxdFdy にマッピングする必要があります
    • dFdy-dFdx にマッピングする必要があります
  • 270 度で事前回転されたフレームの場合
    • dFdx-dFdy にマッピングする必要があります
    • dFdydFdx にマッピングする必要があります
  • 180 度で事前回転されたフレームの場合
    • dFdx-dFdx にマッピングする必要があります
    • dFdy-dFdy にマッピングする必要があります

まとめ

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

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

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