この記事の続きです。
原文:
概要(Overview)
この章はVulkanとそれが取り扱う問題についての紹介から始まります。その後、最初の三角形に要求される構成要素を見ていきます。これはあなたにこれ以降に続くそれぞれの章の大局観を与えます。我々はVulkan API の構造と一般に使われるパターンをカバーして締めくくります。
Vulkanの起源(Origin of Vulkan)
それ以前のグラフィックスAPIと同じように、VulkanもGPUを抽象化してクロスプラットフォームであるように設計されました。それらのAPI1の問題点は、設計された時代のグラフィックスハードウェアは、機能が固定されていて変更可能な部分は限られていたということです。プログラマは決められた方法で頂点データを提供しなければならず、GPUメーカーの用意したライティングとシェーディングのオプションに従うほかありませんでした。
グラフィックスカードのアーキテクチャの成熟に伴い、よりプログラマブルな機能が提供され始めました。これらの新しい機能は既存のAPIに何とかして統合される必要がありました。その結果、抽象化は理想よりも少なくなり、プログラマの意図をグラフィックスアーキテクチャにマッピングするためにグラフィックスドライバ側の当て推量が多くなりました。これはゲームのパフォーマンスを向上するために多くのドライバアップデートがある理由であり、ときに大きな差となります2。これらドライバの複雑さが原因で、アプリケーション開発者は(シェーダ で許容されるシンタックスのような)ベンダーごとの一貫性のなさに対処する必要があります。新しい機能は別として、過去10年でパワフルなグラフィックスハードウェアを搭載したモバイル端末がたくさん登場しました。これらのモバイルGPUは省電力や省スペースといった要求から、違ったアーキテクチャをしていました。例えばタイルドレンダリングのように、プログラマが機能をより制御できるようにすることで、パフォーマンスを向上するというメリットがあるものでした。APIが古臭いことによるもう一つの制限は、限られたマルチスレッドサポートで、これはCPU側のボトルネックの原因になっていました。
Vulkan はこれらの問題を解決するために、モダンなグラフィックスアーキテクチャ向けにスクラッチから設計されました。プログラマに冗長なAPIを使って意図をはっきりと明示させることによりドライバのオーバーヘッドは減少し、またマルチスレッドでコマンドを並列に発行することができるようにしました。シェーダのバイトコードフォーマットを標準化し、シェーダの一貫性のなさを減らしました。最後に、グラフィックス機能とコンピュート機能を一つのAPIに統一することでモダングラフィックスカードの汎用計算の能力を認めます。
三角形を描画するために何が必要か (What it takes to draw a triangle)
Vulkanで三角形を描画するための全てのステップの概要を見ていきます。ここで紹介しているコンセプトは次の章で作り込んで行きます。ここでは個別のコンポーネント同士の関係性についてのビッグピクチャーだけを提示しておきます。
Step 1 - インスタンスと物理デバイスの選択 (Instance and physical device selection)
VulkanアプリケーションはVkInstance
を通して Vulkan API をセットアップするところから始まります。インスタンスはアプリケーションについてと使用されるAPI拡張に関しての記述をもとに作成されます。インスタンスが作成された後、Vulkanがサポートされているハードウェアに関して問い合わせ、1つ以上のVkPhysicalDevice
を選択することができます。選択されたデバイスについてVRAMサイズのような能力(device capabilities)を問い合わせることができ、例えば専用グラフィックスカード3を優先して使用したりできます。
Step 2 - 論理デバイスとキューファミリー (Logical device and queue families)
使用する物理デバイスを選択した後、あなたはVkDevice
(論理デバイス)を作る必要があります。そのとき、マルチビューポートレンダリングや64ビットfloatのようにVkPhysicalDeviceFeatures
で使う機能を具体的に記述します。あなたはどのキューファミリーを使うかを指定する必要もあります。Vulkanで実行される(ドローコマンドやメモリ操作などの)ほとんどの命令は、VkQueue
にサブミットされることによって、非同期的に実行されます。キューはキューファミリーから割当られ、それぞれのキューファミリーは特定の命令セットをサポートします。例えば、グラフィックス、コンピュート、メモリ転送を別々のキューに分割することができます。キューファミリーの能力は、物理デバイスを選択するときに大事な要素となるかもしれません。Vulkanをサポートするデバイスがグラフィック機能をいっさい提供しないということも可能ですが、こんにちのグラフィックスカードであれば、私達が興味がある全てのキュー命令をサポートしているでしょう。
Step 3 - ウィンドウサーフェスとスワップチェイン (Window surface and swap chain)
オフスクリーンレンダリングにしか興味がないというわけでない限り、あなたはレンダリングした画像を表示するウィンドウを作成する必要があります。ウィンドウはプラットフォームのネイティブAPIか、またはGLFW
やSDL
のようなライブラリを使って作成することができます。このチュートリアルではGLFWを使いますが、これに関しては次の章でさらに記述します。
ウィンドウにレンダリングするにはさらに2つのコンポーネントが必要です。それがウィンドウサーフェス(VkSurfaceKHR
)とスワップチェイン(VkSwapchainKHR
)です。接尾辞KHR
は、それがVulkan拡張の一部だということを意味しています。Vulkan API それ自身は完全なプラットフォーム不可知論者なので、ウィンドウマネージャと関わるためには標準化された WSI (Window System Interface) 拡張を使う必要があります。サーフェスはレンダリングするウィンドウのクロスプラットフォームな抽象化であり、一般的にネイティブウィンドウハンドル(例えばWindowsならHWND
)を与えることによって生成されます。幸運なことに、GLFWライブラリはプラットフォーム固有の詳細について扱う組み込み関数を持っています。
スワップチェインはレンダーターゲットの集まりです。それの基本的な目的は、現在レンダリングしようとしている画像と、画面に表示されているものが別々であることを確実にすることです。完全な画像だけが表示されることを保証することは重要なことです。フレームを描画したいときはいつも、スワップチェインにレンダリングに使う画像を提供してもらうように問い合わせる必要があります。フレームの描画が終わると、画像はスワップチェインに返却され、いつかの時点で画面に表示できるようになります。レンダーターゲットの数と画面に表示するための描画完了した画像の数は、プレゼントモードに依存します。一般的なプレゼントモードは、ダブルバッファリング(vsync)とトリプルバッファリングです。我々はスワップチェインを作成する章でこれについて見ていきます。
いくつかのプラットフォームでは、VK_KHR_display
とVK_KHR_display_swapchain
拡張を通してウィンドウマネージャ無しでディスプレイに直接描画することができます。これによって例えば、画面全体のサーフェスを作り、あなた自身のウィンドウマネージャの実装に使うことができます。
Step 4 - イメージビューとフレームバッファ (Image views and framebuffers)
スワップチェインから獲得した画像にレンダリングするには、それをVkImageView
とVkFramebuffer
でラッピングする必要があります。イメージビューは使われる画像の特定の部分を参照し、フレームバッファはカラー、デプス、ステンシルターゲットとして使われるイメージビューへの参照を持ちます。スワップチェインには多数の画像があるかもしれないので、先にそれぞれに対するイメージビューとフレームバッファを作っておき、描画時に正しいものを選択します。
Step 5 - レンダーパス (Render passes)
Vulkanでのレンダーパスは、レンダリングに使われる画像の種類と、それらがどのように使われるか、そしてその中身がどのように扱われるべきかを記述します。我々の最初の三角形を描画するアプリケーションでは、1つの画像をカラーターゲットとして使い、描画前に単色でクリアしてほしいということをVulkanに伝えます。レンダーパスが画像の種類についてのみ記述するいっぽう、VkFramebuffer
は実際にどの画像がバインドされるかを指定します。
Step 6 - グラフィックスパイプライン (Graphics pipeline)
VulkanでのグラフィックスパイプラインはVkPipeline
オブジェクトを作成することによってセットアップされます。それはグラフィックスカードの設定可能なステート、例えばビューポートのサイズやデプスバッファの演算、そしてVkShaderModule
を使ったプログラム可能なステートなどについて記述します。VkShaderModule
オブジェクトはシェーダのバイトコードから作られます。ドライバはどのレンダーターゲットがパイプラインで使われるかや、どのレンダーパスが参照されているかを知っている必要があります。
従来のAPIと比べてVulkanの特徴的な機能の一つとして、ほとんど全てのグラフィックスパイプラインの設定はあらかじめセットしておく必要があるという部分があります。これはシェーダを切り替えたいときや、頂点レイアウトを少し変更しようと思ったとき、グラフィックスパイプラインをまるごと作り直す必要があるということを意味しています。つまり、あなたが描画で必要とする全ての組み合わせのVkPipeline
オブジェクトを前もって作っておく必要があるということです。例えばビューポートサイズやクリアカラーなど、いくつかの基本的な設定については、動的に変更することができます。全てのステートは明示的に指定される必要があり、例えばブレンドステートのデフォルト値などはありません。
いいニュースは、あなたがやっていることはjust-in-timeコンパイルに対する事前コンパイルに等しいため、ドライバにとって最適化の機会がより多く、実行時パフォーマンスもより予測可能です。なぜなら、グラフィックスパイプラインのスイッチのような大きなステートの変更が、とても明示的になるからです。
Step 7 - コマンドプールとコマンドバッファ (Command pools and command buffers)
前述したように、Vulkanでは実行したい命令の多く(例えば描画命令のような)は、キューにサブミットされる必要があります。これらの命令はサブミットされる前に、最初にVkCommandBuffer
に記録される必要があります。これらのコマンドバッファは、特定のキューファミリーに関連付けられたVkCommandPool
から割り当てられます。シンプルな三角形を描画するために、次のような命令をコマンドバッファに記録する必要があります。
- レンダーパスの開始
- グラフィックスパイプラインをバインド
- 3つの頂点を描画
- レンダーパスの終了
フレームバッファ内の画像はスワップチェインからどの画像を与えられるかに依存するため、我々は可能な画像それぞれについてコマンドバッファに記録し、描画時に切り替える必要があります。別の選択肢としては毎フレームコマンドバッファに記録するという方法もありますが、それはあまり効率的ではありません。
Step 8 - メインループ (Main loop)
描画コマンドがコマンドバッファの中にラップされた今、メインループはかなり簡単です。はじめにvkAcquireNextImageKHR
を使ってスワップチェインから画像を取得します。次にその画像に対して適切なコマンドバッファを選択し、vkQueueSubmit
で実行します。最後に、vkQueuePresentKHR
を呼び出して、スクリーンに描画するために画像をスワップチェインに返却します。
キューにサブミットされた命令は非同期的に実行されます。そのため、実行の順番が正しくなるように保証するためには、セマフォのような同期オブジェクトを使う必要があります。描画コマンドの実行は、画像の取得が完了するまで待つように設定する必要があります。さもなければ、画面に表示するために読み込み中の画像にレンダリングを始めてしまう、ということが起こり得ます。vkQueuePresentKHR
の呼び出しも同様に、レンダリングの完了を待つ必要があります。そのため、我々は2つ目のセマフォを使い、レンダリングが完了したらシグナルを出すようにします。
要約 (Summary)
この目まぐるしいツアーはあなたに三角形を描画する前にやらなければいけないことについて基本的な理解を与えたことでしょう。実世界のプログラムでは、より多くのステップを含みます。例えば頂点バッファを確保したりユニフォームバッファを作成してテクスチャ画像をアップロードしたり、といったことです。それらはのちのちの章でカバーしますが、我々はシンプルなところから始めます。なぜならVulkanの学習曲線は急勾配なもので。我々は頂点バッファを使う代わりに頂点座標を頂点シェーダに埋め込むというズルを行います。これは頂点バッファを管理するには、先にコマンドバッファに慣れ親しんでおく必要があるからです。
要するに、三角形を描画するのに必要なのは:
-
VkInstance
の作成 - サポートされているグラフィックスカード(
VkPhysicalDevice
)を選択する - 描画と表示のために
VkDevice
とVkQueue
を作成する - ウィンドウとサーフェスとスワップチェインを作成する
- スワップチェインの画像を
VkImageView
でラップする - レンダーパスを作成し、レンダーターゲットと使用方法を設定する
- レンダーパス用にフレームバッファを作成する
- グラフィックスパイプラインをセットアップする
- コマンドバッファを確保して、全てのスワップチェインの画像に対する描画コマンドを記録する
- 画像を取得し、正しい描画コマンドをサブミットし、画像をスワップチェインに返却する
たくさんのステップがありますが、それぞれのステップの目的は後の章でシンプルかつクリアになります。もしプログラム全体と一つのステップの関係について混乱したときは、この章に戻ってきてください。
APIコンセプト (API concepts)
この章の締めくくりとして、Vulkan APIがローレベルでどのように組み立てられているかの概要をみていきます。
コーディング規約 (Coding conventions)
Vulkanの全ての関数、enum、構造体はvulkan.h
ヘッダに定義されています。このヘッダはLunarGによって開発されている、Vulkan SDK
に含まれています。このSDKのインストールについては次の章で調べます。
関数は小文字のvk
接頭辞を持ち、enumや構造体のような型はVk
接頭辞を、enumの値はVK_
接頭辞を持ちます。APIは関数にパラメータを渡すときに構造体をよく使います。例えば、オブジェクトを作成するときは一般的に次のようなパターンをしています:
VkXXXCreateInfo createInfo{};
createInfo.sType = VK_STRUCTURE_TYPE_XXX_CREATE_INFO;
createInfo.pNext = nullptr;
createInfo.foo = ...;
createInfo.bar = ...;
VkXXX object;
if (vkCreateXXX(&createInfo, nullptr, &object) != VK_SUCCESS) {
std::cerr << "failed to create object" << std::endl;
return false;
}
Vulkanの構造体の多くは、sType
メンバで構造体の種類を明示的に指定することを要求します。pNext
メンバは拡張構造体を指すことができますが、このチュートリアルでは常にnullptr
となります。オブジェクトを作成、または破棄する関数はVkAllocationCallbacks
パラメータを持ち、これによってドライバメモリのカスタムアロケータを使うことができます。これも、このチュートリアルでは常にnullptr
にしておきます。
ほとんど全ての関数はVkResult
を返し、VK_SUCCESS
またはエラーコードを表します。仕様書には、どの関数でどのエラーコードを返し、どのような意味かが記述されています。
バリデーションレイヤー (Validation layers)
前述したように、Vulkanはハイパフォーマンスでドライバのオーバーヘッドが少なくなるように設計されています。そのため、デフォルトではエラーチェックとデバッグの機能がとても制限されています。もしあなたが何か間違ったことをしたとき、ドライバはエラーを返す代わりに頻繁にクラッシュします。もっと酷い場合、あなたのグラフィックスカードではうまく動いていたのに、他のグラフィックスカードではまったく動かないということもあります。
Vulkanでは、バリデーションレイヤー(validation layers)として知られる機能によって、追加のチェックをすることを可能にしています。バリデーションレイヤーはAPIとグラフィックスドライバの間に挿入されるコードで、実行時に関数のパラメータとメモリ管理についての追加のチェックといったようなことを行います。これの良い点は、開発時にはこの機能を有効にしておき、リリース時には無効にすることでアプリケーションのオーバーヘッドをゼロにすることができます。誰でも独自のバリデーションレイヤーを書くことができますが、LunarGのVulkan SDKは標準的なバリデーションレイヤーを提供しており、このチュートリアルではそれを使います。また、レイヤーからのデバッグメッセージを受け取るためには、コールバック関数を登録する必要があります。
Vulkanは全ての命令が明示的で、バリデーションレイヤーも高機能なので、実はOpenGLやDirect3Dと比べるとスクリーンが真っ暗な理由を探すのは簡単です!
コードを書く前にあと一つだけステップがあります。それは開発環境のセットアップです。