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 4 XL で出荷タイトルを実行する場合、SurfaceFlinger(Android コンポジタを操作する優先度の高いタスク)が次の条件を満たしていることがわかっています。

  • アプリの処理を定期的にプリエンプトし、フレーム時間への 1 ~ 3 ミリ秒のヒットを発生させる。

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

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

上記の例のように、サーフェスの回転がオーバーヘッドを可能な限り少なく適切に処理するには、メソッド 3 を実装する必要があります。これを事前回転といいます。これにより、アプリがサーフェスの回転を処理することを Android OS に伝えます。これを行うには、スワップチェーンの作成時に、向きを指定するサーフェス変換フラグを渡します。これにより、Android コンポジタ自体の回転が停止します。

サーフェス変換フラグの設定方法を知っておくことは、どの Vulkan アプリでも重要です。アプリは、複数の向きをサポートする傾向があるほか、1 つの向きをサポートする傾向があり、レンダリング サーフェスの向きは、デバイスが本来の向きとみなす向きとは異なります。たとえば、本来は縦向きのスマートフォンにおける横向き専用アプリと、本来は横向きのタブレットにおける縦向き専用アプリを考えてみてください。

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 に基づいて幅と高さを入れ替えます。

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. 新しいスワップチェーンの DisplayImages でフレームバッファを再作成します。注: 添付ファイル画像(奥行き/ステンシル画像など)は、事前に回転されたスワップチェーン画像の 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 変数はビューポートの左上隅の座標を定義し、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 の事前回転