これはドリコム Advent Calendar 2016 18日目です。

はじめに

ドリコムでクライアントエンジニアをしている増野です。この記事では Khronos Vulkan の概要の説明と、デバイスの初期化周りの解説を行います。実際に三角形一枚を表示するのに、かなりの工程を得ないといけないのですが、それが Vulkan を始めとして低レベルグラフィックス API の面白さでもあります。

低レベルグラフィックスAPI

ここ数年、グラフィクスAPI は一つの転換期を迎えようとしています。従来、据え置きゲーム機では一般的であった、低レベル・低オーバーヘッドグラフィクスAPI の思想が一般化し AMD Mantle を始めとしてそれに追従する形で Direct3D12Apple Metal などの API の誕生が続きました。

そして 20162月にバージョン1.0がリリースとなった Khronos Vulkna もこの流れを汲んだ上で、据え置きゲームやPC、モバイルのように異なるハードウェア環境・OS に置いても統一的な グラフィックスAPI を提供できるように設計されています。

20年以上もの間、グラフィックスAPI は OpenGL / DirectX が標準となっていましたが、2016年以降は徐々に Vulkan への移行が進んでいくことが予想されます。

記事の対象者

Vulkan は GPU ハードウェアから見れば新しい API という訳ではありません。そのため、数年前の古い GPU でもドライバのアップデートのみで Vulkan アプリケーションを動作させることが可能です。低レベルグラフィックス API の思想により、今までドライバが暗黙のうちに担当してきた多くの処理が開発者マターに変わったため記述するコード量は増えていますが、グラフィックスプログラミングの考え方そのものは既存のものと異なることはありません。

つまり Vulkan アプリケーションの開発には必然的に既存のグラフィックスAPI とレンダリングパイプラインの知識が求められます。今回、Vulkan の記事を書くにあたり、以下の方を対象としています。

  • CPU、GPU、メモリなどのコンピュータハードウェアに関する知識
  • リアルタイム3Dグラフィックスのレンダリングパイプラインへの知識
  • OpenGL / DirectX など、既存の グラフィックスAPI に関する知識

元々グラフィクスAPI の役割は、GPU デバイスにシェーダプログラムとリソースを転送し、結果を CPU 側で受け取ることにあります。そのため、Vulkan を使用しても OpenGL を使用してもそれが動作するハードウェア同じということは頭に置いておく必要があります。

グラフィックスAPI 周りの話は、Tech Inside Drecom にある DrawCall (ドローコール) って何? 描画パフォーマンスを考える一つの指標を見る も合わせてご参照下さい。

Vulkan 開発環境を用意する

Vulkan はマルチプラットフォームに対応した API です。2016年12月現在、Windows, Linux, Android (7.0 nougat 以上) が Vulkan をサポートしています。今回の記事では、Windows + Visual Studio 2015 での開発を主眼に置いています。

GPU の対応状況

Vulkan を使用したアプリケーションを動作させるためには、Vulkan に対応した GPU が必要です。下記の GPU ベンダーのサイトに Vulkan 対応製品の一覧が書かれているため、お使いの環境に合わせて確認を行って下さい。

また、対応した GPU をお持ちの場合でも、Vulkan を動作させるためにドライバのアップデートが必要になる場合があります。詳しくは各 GPU ベンダーごとのサポートページをご覧下さい。

Vulkan SDK のインストール

Windows, Linux 用の SDK は下記ページよりダウンロード可能です。2016年12月現在、SDK は 1.0.33 が最新です。(※12月11日に 1.0.37 がリリースされました

上記のページは、Vulkan API のドキュメントやチュートリアルなど、Vulkan 開発に必要な情報がまとめられています。ユーザー登録なしでも SDK のダウンロードやドキュメントへのアクセスは可能ですが、開発元へ SDK のフィードバックを出す場合にはアカウントが必要になります。Vulkan SDK は頻繁にアップデートが行われているので、可能であれば常に最新の SDK で開発されることをおススメします。

DEMO の動作

Windows 環境において Vulkan SDK は、デフォルトでは下記フォルダにインストールされます。

  • C:\VulkanSDK\

更にインストールしたバージョン番号のフォルダが作られており (1.0.33.0 等)、その下に SDK の中身が存在します。Samples というフォルダが中にありますが、今回は Demos フォルダの方を利用します。

Samples にあるサンプルの実行には、環境に合わせて CMake と Python のインストールが別途必要になりますが、Demos の方には Visual Studio のソリューションファイルが直接含まれているので Windows でとりあえず Vulkan を動かしたい場合はこちらがおススメです。

Demos.sln の実行

バージョン1.0.33.0 の SDK において、「C:\VulkanSDK\1.0.33.0\Demos\DEMOS.sln」を開くと、3つのプロジェクトファイルが含まれています。

  • cube
  • cubepp
  • vulkaninfo

今回は、Visual Studio から「cube」をスタートアッププロジェクトに設定して実行します。ビルドが成功すれば下記のサンプルプログラムが起動します。

Vulkan 自体は C のAPI として策定されていますが、C++ でバインドした「Vulkan-Hpp」というラッパークラスが存在します。以前は NVIDIA から提供されていましたが、現在は Khronos グループの管理下に移っています。

「cubepp」のプロジェクトでは、この Vulkan-Hpp を利用した C++ のサンプルになっています。「cube」と「cubepp」の違いは Vulkan API を呼び出す手続きが C か C++ かの違いによるものなので、実行された見た目の結果としては「cube」と「cubepp」は同一です。

Vulkan が最初から C の API として策定されているのは、今までの OpenGL 開発者への配慮のためでしょうか。しかし、Direct3D (あるいは Metal)からの移住者も考えて、このように扱いやすい C++ のラッパーが提供されているのだと思います。

そのため、少し大げさに書くと「Vulkan C」と「Vulkan C++」という二つの API が存在していることになりますが、今回は利便性より分かりやすさを優先するため「Vulkan C」を利用します。

Hello Cube

「Cube」はテクスチャが貼られたボックスが回転しているデモプログラムですが、実行に必要なコードは Windows だけで約3000行あります。(Window 作成部分や、モデルデータが直接コード上に記述されているため実際の Vulkan コードはもっと少ない)

プログラム全体の概要は以下のようになっています。

demo_init_vk Vulkan API の初期化、インスタンスや物理デバイスの作成、Queue Family の取得
demo_init_vk_swapchain 描画サーフェスの取得、スワップチェインの作成、デバイスキューの取得
demo_prepare コマンドプール、コマンドバッファ、各種フレームバッファ、モデルデータ、Descriptor の記述、シェーダーなど描画リソースの作成
demo_run リソースの更新(このデモであれば、回転行列のアップデート)、DrawCall コマンドの発行、Present 処理
demo_cleanup Vulkan API の終了処理

この記事では demo_init_vk 部分を見ていきます。

vkInstance

Vulkan プログラミングでは、vkInstance を作成することから始めます。VkInstance は Vulkan API に関する上位のハンドラーと思って頂ければ良いです。VkInstance の作成のためには VkApplicationInfo 構造体と VkInstanceCreateInfo 構造体が必要です。1つのアプリケーション内では複数の VkInstance を持つことが可能ですが、ゲーム制作においては一つ作れば十分です。

    const VkApplicationInfo app = {
        .sType = VK_STRUCTURE_TYPE_APPLICATION_INFO,
        .pNext = NULL,
        .pApplicationName = APP_SHORT_NAME,
        .applicationVersion = 0,
        .pEngineName = APP_SHORT_NAME,
        .engineVersion = 0,
        .apiVersion = VK_API_VERSION_1_0,
    };

VkApplicationInfo には複雑な定義はありません。アプリケーションの名前や使用する Vulkan のバージョンなどを代入します。他のグラフィックスAPIでもそうですが、Vulkan でも構造体などに指定するパラメータは暗黙値(今後の拡張のため予約されている)であることが多いです。sTypeVK_STRUCTURE_TYPE_APPLICATION_INFO を指定し、また pNext には NULL を指定する必要があります。この辺りの詳細は構造体ごとにリファレンスを参照して下さい。ここで定義した構造体のポインタを VkInstanceCreateInfo に指定します。

    VkInstanceCreateInfo inst_info = {
        .sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO,
        .pNext = NULL,
        .pApplicationInfo = &app,
        .enabledLayerCount = demo->enabled_layer_count,
        .ppEnabledLayerNames = (const char *const *)instance_validation_layers,
        .enabledExtensionCount = demo->enabled_extension_count,
        .ppEnabledExtensionNames = (const char *const *)demo->extension_names,
    };

VkInstanceCreateInfo には、Vulkan アプリケーションで使用する、レイヤーや拡張機能を代入します。Vulkan API はこのレイヤーを定義することでコア部分以外の機能追加を実現しています。例えば ValidationLayer や、API のプロファイリング、ロギングなどの機能がレイヤーとして提供されています。レイヤーとは別に、拡張(例えば、各プラットフォームごとのサーフェイスへのアクセスなどの)機能もこの構造体にまとめて指定します。

vkCreateInstance(&inst_info, NULL, &demo->inst);

Vulkan インスタンスの作成が完了すれば、戻り値として VK_SUCCESS が返り、第三引数に指定した vkInstance にインスタンスの ID が入ります。

VkPhysicalDevice

vkInstance を取得できた後は、VkPhysicalDevice の作成を行います。Vulkan には物理デバイス(VkPhysicalDevice)と論理デバイス(VkDevice)の2つのデバイスがあります。物理デバイスはその名前の通り、GPU のようなシステムに搭載されているハードウェアそのものを表しています。そして論理デバイスは、物理デバイスをソフトウェアで抽象化したようなものです。主にアプリケーションから使用するのは論理デバイスです。

VkPhysicalDevice を作成するためには、まずはシステムに搭載されている物理デバイスの数を取得します。そして、数が分かったら実際に VkPhysicalDevice を取得します。この2つの操作は、同じ vkEnumeratePhysicalDevices 関数を使用します。マルチ GPU を使用している場合は、gpuCount が 2 になります。

    /* Make initial call to query gpu_count, then second call for gpu info*/
    err = vkEnumeratePhysicalDevices(demo->inst, &gpu_count, NULL);
    assert(!err && gpu_count > 0);

    if (gpu_count > 0) {
        VkPhysicalDevice *physical_devices = malloc(sizeof(VkPhysicalDevice) * gpu_count);
        err = vkEnumeratePhysicalDevices(demo->inst, &gpu_count, physical_devices);
        assert(!err);
        /* For cube demo we just grab the first physical device */
        demo->gpu = physical_devices[0];
        free(physical_devices);

cube のサンプルでは、物理デバイスは1つのみ使用するようになっています。物理デバイスの作成が終われば、各種の GPU の機能(Feature Level)について取得が行えます。

Device Queue

物理デバイスの作成が終わった後、次に重要なのが「Queue(キュー)」の取得です。ここで言うキューとは、GPU 内部のキューの事を指しており「GPU Hardware Queue」などと呼ばれています。

GPU はグラフィックスのタスクとコンピュートのタスク(この2つに依存関係がない)が一つのキューに混在している場合はこれを並列実行することは出来ません。キューに追加された順番により、どちらかのタスクの完了を待ちます。また、テクスチャやバッファなどのリソースを GPU で使う場合は、CPU から GPU のメモリ空間へのアップロード(転送)が入ります。キューが一つしかない場合は、GPU はメモリの転送を待ってから処理を開始しなければならず、その間 GPU ストールが発生します。複数のキューが存在出来ることで GPU 内のタスク処理を非同期に実行させることが可能になっています。

キューはデバイスによって使用できる種類と数が異なります(この総称を Queue Family と呼びます)。Queue Family を調べるにはvkGetPhysicalDeviceQueueFamilyProperties 関数を利用して、物理デバイスに存在するキューの数を確認します。

	/* Call with NULL data to get count */
	vkGetPhysicalDeviceQueueFamilyProperties(demo->gpu, &demo->queue_family_count, NULL);
	assert(demo->queue_family_count >= 1);

	demo->queue_props = (VkQueueFamilyProperties *)malloc(demo->queue_family_count * sizeof(VkQueueFamilyProperties));
	vkGetPhysicalDeviceQueueFamilyProperties(demo->gpu, &demo->queue_family_count, demo->queue_props);

そして数が分かったら、もう一度 vkGetPhysicalDeviceQueueFamilyProperties を呼び出し、このデバイスで使用可能なキューの種類を取得します。私の環境では Queue Family は下記のような結果が返りました。

Queue Family

VkQueueFamilyProperties 構造体が一つのキューを表しています。この構造体には queueFlags があり、これがキューで実行可能なタスクのビットの値が入ります。

  • VK_QUEUE_GRAPHICS_BIT = 0x00000001, グラフィックス操作のキュービット
  • VK_QUEUE_COMPUTE_BIT = 0x00000002, コンピュート操作のキュービット
  • VK_QUEUE_TRANSFER_BIT = 0x00000004, バッファ、テクスチャの転送用のキュービット
  • VK_QUEUE_SPARSE_BINDING_BIT = 0x00000008, スパースリソースのアップデートなど、メモリ管理用のキュービット

一つ目の queueFlags のビットが 15 (0b1111) を指しているため、このキューでは全ての操作が可能です。もう一つは 4 (0b0100) になっているため、リソース転送専用です。queueCount はこのキューから生成出来る VkQueue の数だと思われます。

VkDevice

物理デバイスの作成が終わり、キューの情報が所得できたら論理デバイス(VkDevice)の作成に入れます。ここでは VkDeviceQueueCreateInfo 構造体を作成し、VkDeviceCreateInfo に渡します。重要なのは、VkDeviceQueueCreateInfo に指定する、queueFamilyIndex です。

vkGetPhysicalDeviceQueueFamilyProperties で取得した Queue の index を代入します。ここで複数の VkDeviceQueueCreateInfo を使用しているのは、VkDevice に紐付ける「GPU Hardware Queue」を指定しているということでしょうか。環境によっては、画面描画用の Present Queue が独立していることもあるため、cube のデモプログラムでは demo->separate_present_queue をチェックして、Queue の2つめを指定しています。

    VkDeviceQueueCreateInfo queues[2];
    queues[0].sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO;
    queues[0].pNext = NULL;
    queues[0].queueFamilyIndex = demo->graphics_queue_family_index;
    queues[0].queueCount = 1;
    queues[0].pQueuePriorities = queue_priorities;
    queues[0].flags = 0;

    VkDeviceCreateInfo device = {
        .sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO,
        .pNext = NULL,
        .queueCreateInfoCount = 1,
        .pQueueCreateInfos = queues,
        .enabledLayerCount = 0,
        .ppEnabledLayerNames = NULL,
        .enabledExtensionCount = demo->enabled_extension_count,
        .ppEnabledExtensionNames = (const char *const *)demo->extension_names,
        .pEnabledFeatures =
            NULL, // If specific features are required, pass them in here
    };
    if (demo->separate_present_queue) {
        queues[1].sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO;
        queues[1].pNext = NULL;
        queues[1].queueFamilyIndex = demo->present_queue_family_index;
        queues[1].queueCount = 1;
        queues[1].pQueuePriorities = queue_priorities;
        queues[1].flags = 0;
        device.queueCreateInfoCount = 2;
    }
    err = vkCreateDevice(demo->gpu, &device, NULL, &demo->device);
    assert(!err);

vkCreateDevice を呼び出せば、VkDevice が取得できます。ここまでで論理デバイスの作成が終われば、無事に Vulkan API を使用する環境が整いました。

先は長いので今回の記事ではここまでとします。

まとめ

従来のグラフィックス API ではブラックボックス化されていた部分が多くあり、開発者からはその挙動が読めない側面が多くありました。Vulkan を始め低レベルグラフィックスAPI の誕生により、API の透明性が確保され、今まで以上にグラフィックスの処理速度を考えられるようになったことは開発者にとって非常に喜ばしいことのように感じています。