こんにちは、isskです。情報工学科3年です。年齢は秘密です。
ロボットのサークルにいるのですが、私はゲームを作っています。

これはrogy Advent Calendar 10日目の記事です。

【アップデート】消し忘れていた部分を消しました。「感想」と「Q&A」を追加しました。

vulkan_lava

"Vulkanを使っただけでゲームが面白くなるわけではない。何故使うんですか?"
"そこに(火)山があるから──。"
-レベル4の回答より- 

まえがき

最近私は趣味でVulkanを勉強しています。
Vulkanと言うのは最近公表された"Low-Overhead API"の一つです。
並列化に強いとのウワサです。
OpenGLとドが付くほど勝手が違うので驚いています(詳細は下に)。

そして、学習の一貫として、フレームワークを作りました
  vulkan-study-framework

(Windowsに限って一応実行できますが、設計とか実装自体は明らかに途中段階です)

で、リアルタイムパストレーシング(レイトレを拡張したようなものです)をしてみました。

(Win + Alt + Rがいつまでも録画してくれなかった)
この記事ではこれを実現するためのVulkanの記述について解説していこうと思っています。
自分へのノートも兼ねています。

注意

筆者はDirectXを知りません。
筆者は浅学なので、以下の解説は間違っている事があります。
誤記を見つけたら後学の為、また閲覧者の誤解を与えないために訂正下さい。お願いします。

Vulkanの特徴

C言語で書かれたダッシュ島です。

語句解説

読む途中でこの語句分からない、となったらここを確認すると助けになるかもしれません。

インスタンス

Vulkanの初期化をする構造体を示しており、 Vulkanアプリケーションの状態は全てインスタンスに格納されるので、インスタンスを作成しないとVulkanに情報を渡せません。

物理デバイス

レンダリングに関する計算に使用するハードウェア(GPU)の事を表す構造体のことです。

論理デバイス

簡単に言うと人間に対する物理デバイスのインターフェースです。

サーフェス

レンダリング結果を人間から見える様に表示する対象物です。レンダリング結果が見える必要が無いならサーフェスを作らないという選択もできます。(オフスクリーンレンダリングと言うらしいです)

レンダーターゲット

レンダリングの対象である画像バッファの事を指します。

SPIR-V

中間言語です。GLSLがこれに事前にコンパイルされます。コンパイルされたSPIR-VバイナリをVulkanがシェーダとして扱います。OpenGLでも拡張で使えるんですよ。

スワップチェーン

レンダーターゲットを提供するインフラです。Vulkanではレンダーターゲットの円形状の集合体のようです。ダブルバッファリングとかを実現します。DirectXでは馴染みらしいですね。

イメージビュー

イメージをどの様に扱うか決定するものです。(このイメージはデプステクスチャとして扱う、等)

グラフィックスパイプライン

与えられた頂点情報やテクスチャ情報をレンダーターゲット内のピクセルにするための一連の操作の流れです。

コマンドバッファ・コマンドプール

コマンドバッファは描画命令などを記録するオブジェクトです。コマンドバッファはコマンドプールから割り当てられます。直接作らないの意です。

レンダリングパス

完全に新しい概念です。わかってません。 これはあるアタッチメントがどのように処理されるか、を取り決め、これらを集めて依存性を持たせているもの、という理解をしています。(ちょっと自信ないです、、、、)。 また、これの恩恵を得る機会はこの記事には多分ありません。

フレームバッファ

誤解を恐れない発言をすると、イメージビューをアタッチメントそのものとして持たせ、レンダリングパスにあるアタッチメントの説明と紐付するものです。(OpenGLのフレームバッファの意味少し広い版らしいんですけど、自分はOpenGLを使っていた頃フレームバッファをただのテクスチャの集まり程度にしか考えていなかったので、難しいですね、、、)レンダリングパスのアタッチメントに設定された計画通りの動作を行うようになります。

Vulkanを使ってみる

「御託はいいからコードを見せろ!」
「おk!わかった!まずVulkanのインスタンスを作成するぞ!」
もう既にイヤんなってきますね。
自分は頑張って↑のような書き方で三角形をウィンドウに表示(Hello Triangle)させたら800行くらいのコード量になりました。Hello Worldに800行かかるんです。
先に述べた通り、Vulkanは C言語のAPIなので、いくつか面倒な事象が発生してしまいます。
構造体にコンストラクタ等は無く、構造体によって代入するものが確定している sTypeフィールド等を一々書かなければならないのが非常に面倒くさい上にミスの温床となっています。
その時、初期化を忘れたフィールドがあると見つけるのが困難な問題になりがちです。
引数に配列を渡す場合、たとえその配列がSTLのベクタであっても配列の要素数(.size())と配列のポインタ(.data())を分けて渡さないと行けません。
いろいろつらいですね。
そこで紹介するのがVulkanのC++によるラッパーであるvulkan.hppです。 大分すっきりしたコードになりましたね。Vulkanの構造体をvk::名前空間のクラスに全てラップして、sTypeはコンストラクタで正しいものに初期化が行なわれています。sTypeの事故はまず起こりません。
コンストラクタが存在するので初期化忘れをすることは先ずなくなりました。
STLをデフォルトでサポートしています。
Vulkanの列挙体は全てenum classに置き換わっています。enumそのものが間違ってたらコンパイル時に弾かれるようになります。(|演算子とかはちゃんと使えますよ)
vk::ArrayProxy<T>というクラス(std::vectorとstd::arrayをコンストラクタの引数に渡せる)があって、これが配列の要素数と配列のポインタを持っているので、引数に要素数とポインタを一々渡す必要がなくなりました。
Optional<T>が実装されているので使えます。
vkCreateImage(device,...)のような、論理デバイスが行うであろう事をする関数は全てvk::Deviceクラスのメソッドになっています(インスタンスとか物理デバイスも同様)。
良いことづくめだし、そもそも Vulkan SDKが標準でこのヘッダファイルを持っているのですから、C++を用いるに当たって使わない手は無いでしょう。

Vulkanでリアルタイムパストレーシングするまで

実はVulkanで上の動画のレンダリング結果を表示させる前に、OpenGLで同じことを実現していました。 ですから、先に作っておいたシェーダのコードをVulkanで扱えるSpir-Vバイナリにコンパイル出来るように微修正して、

1.4つの頂点とUV座標をアトリビュートとして描画できるようにする
2.Uniform Buffer Objectとしてデータをフラグメントシェーダに渡せるようにする

事をすれば、後は既存のシェーダを渡すだけで実現できます。
あまりに記事が長くなってしまうので、コードを載せる際は少しはしょらせて頂きます。

Vulkanを初期化する

・はじめに
Vulkanではほぼすべての操作を コマンドとしてキューに入れて実行させるようになっています。
これも"Low Overhead"のためなのですよ。
この先キューが出てきたら「コマンド群の入れ物だ」と思ってください。
また、**KHRという名前の関数は「これはVulkanの拡張である」ことを示しています。

・インスタンスを作成
大体のVulkanアプリケーションが同じことを行っているでしょう。
ここでインスタンスに 検証レイヤ(Validation Layer)を任意に導入することが出来ます。検証レイヤとはVulkanのエラーチェックシステムです。
デバッグビルドであるときに検証レイヤを導入し、プログラムのミスに因るエラー検出を作ってバグ直しに役立てています。役立ちました。すごい分かりやすいエラーメッセージ。感謝してもしきれません。
また、使用できるVulkanの拡張も記述します。 自分は列挙した配列を予め用意しているだけです。

・サーフェスを作成 OSことにサーフェス作る関数が異なっています。こういう関数は拡張になってますね。
先に述べましたが、Vulkanではオフスクリーンレンダリングを行う上で サーフェスを作らないという選択が可能です。
OpenGLでは見えないウィンドウを作成する必要があったと思われます。

・使う物理デバイスを選ぶ インスタンスは使うかもしれない物理デバイスをすべて列挙することができます。そこから取捨選択が可能です。
ここで「キューファミリー」という語が登場します。
まず、GPUは幾つかのキューを持っています。これらのキューはできることが限られていることがあります。
参考元によれば、NVIDIA製はなんでも出来るキューを16個もっており、AMD製は{1つのグラフィックス/計算両用キュー,1~4個の計算用キュー,2つの転送用キュー}をもっているらしいです。
このキューのうち、同じ機能を持つキューの集合が一つのキューファミリーです。「なんでも出来るキュー16個」のNVIDIAはキューファミリー1つですね。
で、このキューファミリーの番号を保持しておきます。あとで使うためです。

・論理デバイスを作成 物理デバイスが論理デバイスを作成する役割を持っています。論理デバイスにも検証レイヤを導入することができます。
物理デバイスを選ぶ時に、キューファミリーの番号を保持していましたが、これは実際に使用するキューをvk::Queueとして取得するのに必要だったからです。
キューは描画用のキューと、ウィンドウに表示させる用のキューです。

・コマンドプールを作成
コマンドプールはメモリプールであり、そのバッファの中から適当なコマンドバッファを割り当てます。
作成でやることはこれだけです。
以上の{インスタンス作成,サーフェス作成(任意),物理デバイス選択,論理デバイス作成,コマンドプール作成(これも?)}が初期化の流れです。

4つの頂点とUV座標をアトリビュートとして描画できるようにする

・スワップチェーンを作る
スワップチェーンを作る部分をあえて初期化の段階に入れなかったのは、スワップチェーン内のレンダーターゲットの大きさをスワップチェーンを作り直さないと変えられないので、 例えばウィンドウの大きさが変わった時等はスワップチェーンを作り直さなければならないからです。 (Vulkanは冗長なAPIと良く言われておりまして、、、、)
これは、スワップチェーンのプロパティを唯一に選んで、キューファミリーによる設定の違いに考慮しながらcreateInfoを設計しています。
スワップチェーンを作り直す際も同じ関数を読んでいるのですが、「スワップチェーンを初めて作成するか、スワップチェーンを置き換えることをするか」というのはinfo.oldSwapchainの有無によって判別されるようです。

・レンダーパスを作る
上の語句解説で大分端折っていたレンダーパスについて分かる範囲で説明します。 あるアタッチメントがどのようなものであるかを書き(AttachmentDescription)、処理の記述(SubpassDescription)にそのアタッチメントの参照(AttachmentReference)を渡しています。
サブパスはカラーアタッチメントの参照を持つもの単一であるため依存関係は設ける必要がありません。以上まとめてRenderPassCreateInfoにしてレンダーパスを作成。後にレンダーパスのアタッチメント記述とスワップチェーンのイメージのビューを紐付けるフレームバッファを作ればおk!
wakaranaikao(依存関係って何....)
はい。
レンダーパスにサブパスを複数設置して、たとえばこれらをサブパスA、サブパスBとした場合、「サブパスBはサブパスAの入力が必要なので、サブパスBはサブパスAの後に照会されなければならない」ということがあります。
これを記述できるのが「サブパス依存関係(SubpasDependency)」です。Vulkanがコマンドを並列処理するために、複数のパスの照会順番が分からないことから生まれた模様です。
(サブパスを複数設置するもので、自分がぱっと思いつくのはポストプロセスですね。)

・グラフィックスパイプライン作成 めちゃくちゃ端折ってしまったのですが、グラフィックスパイプラインの各段階に情報を挿入するvk::Pipeline[StageName]CreateInfoを渡しています。

・頂点バッファとインデックスバッファ作成 バッファを2つ作って、StagingBufferの方にデータをマッピングしてから、バッファをVertexBufferに移しています。
これはBuffer作成の際使用されるvk::MemoryPropertyFlagBitsによるBufferの特性に関係しています。
グラフィックスカードがデータを読み出すのに最適なバッファのプロパティはvk::MemoryPropertyFlagBits::eDeviceLocalです。
しかしながら、このプロパティのバッファのメモリはCPUからアクセスできません
そこで、CPUからアクセス出来るプロパティでバッファを作ってから、一時的に取得したコマンドバッファを介してバッファをコピーする方策を撮っています。
これはバッファのステージングと呼ばれていて、DirectXではお馴染みの様ですが、自分は今まで知りませんでした。
インデックスバッファも同様の方法で作っています。

・コマンドバッファに描画命令を記録する コマンドプールから割り当てられたコマンドバッファ全てに対して、レンダーパスを開始してから描画命令を記録しています。コマンドバッファは、スワップチェーン内のフレームバッファそれぞれに割り当てられるので、コマンドバッファの数はスワップチェーン内のフレームバッファの数と同じです。

描画と表示

ここから毎フレームごとの話になります。
今、ここに描画命令の記録されたコマンドバッファがあります。 スワップチェーンからレンダーターゲットを(1つ)取得します。この時、「レンダリングする準備が整った」という信号を出すオブジェクトであるセマフォを登録してます。
レンダーターゲットに対応したコマンドバッファをグラフィックスキューに入れてからコマンドバッファに記録されたものが先のセマフォの信号を検知したらsubmitから実行されています。
「ウィンドウに表示する準備が整った」という信号を出すセマフォを使い、そのタイミングでプレゼントキューからウィンドウにレンダリング結果を表示しています。

レンダリングまでの作業が終わりました。びっくりするほど長かったですね。
しかしながら、まだUniform Buffer Objectという仕事が残っております・・・

・Uniform Buffer Objectを扱う
バッファを作る部分は頂点バッファやインデックスバッファと全く同じです。
Uniform Bufferを渡す場合は、そのほかにディスクリプタセットを作る必要があります。 コマンドバッファに記録している(上の"コマンドバッファ作成"の部分)コードの22行目にbindDescriptorSetsという関数がありますね。 そこにUBOの情報の入ったディスクリプタセットを渡しています。 そうすることで、後はUBOのデータを任意のタイミングで更新して、バッファのメモリを書き換えることでシェーダでのUBOの値も更新されます。
最終的なレンダリングが、上のパストレーシングの結果となります。

フレームワークにしてみる


普通にこの膨大な作業をするのは辛いので、その作業量を減らすためにフレームワークの実装に着手しました。
やっていることはvulkan.hppをさらにラッピングする、というスタンスです。
既に作っていた(OpenGL向けの)ライブラリの一部を転用しています。(数学関連、ウィンドウ等)

Ore::Instance

物理デバイスを作成するメソッドを持っています。 また、サーフェスも持っており、作成しています。

Ore::PhysicalDevice

論理デバイスを作成するメソッドを持っています。 インスタンスのshared_ptrを持っています。

Ore::Device

物理デバイスのshared_ptrを持っています。 色々なVulkanのオブジェクトを作成する関数を実装している 最中です。

その他

共通することは論理デバイスのshared_ptrを持っていて、デストラクタで自身のvk::**を削除していることなのですが、それ以外は今のところvulkan.hppのクラスをラッピングしているだけです!

まとめ

Vulkanで簡単な図形を描画することと、Uniform Buffer Objectを扱うことを紹介しました。この記事の情報で一応3Dモデルをテクスチャ無しで描画できるはずですよ。

感想

これ人間の書くAPIじゃなくないですか....これのレンダラを採用しているDOOMとかの技術者を改めて「めっちゃ凄いな」と思う冬の空の下です

Q&A

Q-"Low Overhead API"というのは、CPUのオーバーヘッドを減らす目的で策定されたのであって、GPUの計算が大半を占める上のパストレーシングでこれを使うのはあまり意味が無いんj
A-そこに(火)山があるから──。

以上です。ここまでたどり着いた方は、
takeshi
「この記事は全く意味のないものかもしれない」の意


次回のadvent calendarの更新は
rej55さんの「MATLABで3Dモデルを更新しよう!」です。 

ありがとうございました!