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() を呼び出し、返された範囲を保存することです。ID 画面の解像度を保存できるように、同様に返された 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. 新しいスワップチェーンの 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 の事前回転