最近は、8000円の本を電車で見せびらかしています。52代takowasabiです。
今回の記事はクリスマスイヴにぴったりな、子供受けもばっちりの、低レベル3Dグラフィックレンダリングのことをお話ししたいと思います。
具体的には、割と新しめのクロスプラットフォームグラフィックライブラリであるVulkanが、3Dグラフィック描画までの手順をどのように抽象化して提供しているのかについてお話しできればと思いますので、ご興味のある方は是非お立ち寄りください。
Back Ground
MIS.Wには発表会という催しが定期的に行われており、サークル員たちが日々取り組んでいることや、つくったものなどを見せる場として、サークル活動の中でも非情に重要な役割をもっています。
今回お話するVulkanに関しての内容は、その発表会での発表内容の補足という面も持っています。発表会ではこんなことが発表されているんだな、他の人はどんなことを話すんだろう、などと興味を持っていただければ幸いです。
発表の中では、時間のない中での発表だったため、以下のようなスライドでVulkanの描画の手順を説明していました。
そして、ここからは、このスライドにあるお手軽29ステップに沿って、Vulkanで描画に至るためのAPIの操作やその意味についてをお話ししたいと思いますので、その中で、低レベルなグラフィックAPIがどんな風に描画を行っているのかを知っていただければと思います。
そもそものVulkanに関しての説明は、なんかごちゃごちゃまとまってる公式のスライドが公開されていますのでそちらをご覧ください:https://www.khronos.org/assets/uploads/developers/library/overview/vulkan-overview.pdf
実際のコードを含めたチュートリアルはこちらがおすすめです:https://vulkan-tutorial.com/Introduction
さらに詳細な解説は公式ドキュメントをどうぞ:https://vulkan.lunarg.com/doc/sdk/1.1.126.0/windows/chunked_spec/index.html
Detail
1. VulkanのAPIを使用するために必要なInstanceを作成する
道具を使い始めるにはまずスイッチを入れる必要があります。Vulkanで何かするなら、まずはInstanceを作らなくてはなりません。
素晴らしいことに、Vulkanは私たちが何かする前から勝手に動いたり情報を持つことはありません。そこで、Vulkanを使い始めるときには、私たち自身が明示的に、Vulkanを動かすのに必要な"状態"を持ってくれるオブジェクトを作成する必要があります。それがInstanceと呼ばれるものです。
InstanceはVulkanの初期化を行う役割がある他、この後に述べるSurfaceなどのオブジェクトの作成や削除に不可欠なものになります。
2. 今回使用するグラフィックスの要件を満たすデバイスを探す
早速画像の生成に入っていきたいところですが、そもそも画像を作ってくれる人をまだ決めていません。野次馬に救急車を呼んでくれと言っても誰も動かないように、Vulkanでは画像生成などの計算を行ってくれるデバイスを決めないことには、何も動いてくれません。
ここで言うデバイスは、Vulkanを動かすのに必要な機能を持つ、いわゆるグラフィックボードのことです。このデバイスのことを、VulkanではPhysical Deviceと呼びます。
この段階では、Instanceを使って、利用可能な物理デバイスの一覧を取得し、必要な拡張機能やVRAMのサイズに対して問題なく動作してくれるであろうPhysical Deviceを選んでおきます。
3. 画面を描画するウインドウを各OSのやり方に沿って作成する
デバイスを選んだら、次は画像を表示するためウインドウを作成します。
しかし何と、純粋なVulkanのAPIはOSの機能に依存しないものとして作られています。そのために、OSへの依存まみれのウインドウシステムにはあまり干渉することが出来ません。
パソコンの画面上で生成した画像を表示するために必要不可欠なウインドウは、OSごとに定められた方法で作る必要があります。それには、OSのネイティブなAPIを使うこともできるのですが、OpenGLなどの補助ツールでもあるGLFWやSDLを使えば、OSの違いを意識せずにウインドウを作成し、簡単にVulkanとの接続をすることができます。
4. Vulkanでウインドウの表面を表すSurfaceを作成する
先述した通り、VulkanはOSの機能には依存していないため、単体ではウインドウとのやり取りもできません。そこで、拡張機能を利用して、ウインドウの画面での抽象であるSurfaceを作成する必要があります。
ここで必要になる拡張機能は、WSIと呼ばれ、Vulkanの本体とは明確に切り離されたものとなっています。(詳細はこちら)
Surfaceの作成にはOSごとの方法が用意されており、3.で用意したウインドウに関する情報を渡すことで作成することができます。
Surfaceはウインドウ画面への表示に対して重要な役割を担っており、この後に述べるSwapChainの作成に必要であったり、画面表示のための情報を引き出すために使用されます。
5. 物理的なデバイスから、VulkanのAPIで用いるデバイスの抽象を作成する
先ほど、画像生成を任せるために選んだPysical Deviceは、文字通り物理的なデバイスを指し示すだけもので、実はまだVulkanから動かせる状態にありません。Instanceを作成したときのように、デバイスを操作するためにはLogical Deviceというオブジェクトを作る必要があります。
Logical Deviceは、今後述べるほとんどのVulkan上のオブジェクトの作成に必要なもので、デバイスを使って行う全ての処理に関連してくる非常に重要な存在です。
6. デバイスに送るグラフィック系の命令を入れておくためのQueueを取得する
Vulkanの特徴として、ほとんどの操作を非同期に実行できるというものがあります。他のスレッドの処理に縛られず非同期に処理を行うために、命令を一旦取っておくためのキューを用意する必要があります。そのためのキューはQueue Familyと呼ばれ、いくつかの種類があります。
今回Vulkanにやってほしい処理の一つに、画像生成の処理があります。Vulkanを操作しての処理になるため、当然画像生成のためのキューを得る必要があります。そのためには、Physical Deviceがそれに対応していることを確認し、Logical Deviceの生成時に必要情報を伝えて一緒に作成してもらいます。
7. デバイスに送る画面表示の命令を入れておくためのQueueを取得する
さらに今回Vulkanにやってもらわないといけない処理に、生成した画像の表示があります。Vulkanに、そんなことやれなんて言われてないんですけど帰っていいですか?と言われないように、画面表示用のキューも画像生成のためのキューと同じように作成する必要があります。
8. ウインドウの表面に画面の情報を送る前に、画面を描画する対象となるSwapChainを作成する
画面とのやり取りをするためのSurfaceを作成しました。生成した画像をそこに渡せばうまいこと描画してくれるようにも思えますが、そんなおいしい話はありません。
ウインドウに表示される画面は常に一つのように思えますが、Vulkanで扱う画面は内部的に複数の画像を持っており、それらを順番に描画することで表示する画面を変化させています。これは、表示されている画面に直接書き込みを行ってしまった場合の画面のちらつきの防止や、フレームレートの安定化のために必要な工夫です。ここで、画像を内部的に持っている構造は、キューのようになっており、Swap Chainとよばれています。
Swap Chainは画面のサイズや色の表現方法などを伝えて作成する必要があるため、それらが変化するたびに作り直さなくてはならないのが難点です。
9. SwapChain上の画面にアクセスするための画像の抽象を作成する
では、今度こそ画像を描くための対象の準備が終わったかと思えば、そんなことはありません。まだまだ続きます。
Swap Chainが格納しているのは間違いなく画像ではあるのですが、その画像に対して操作を行うためにはその画像のハンドルを取得し、操作を行うためのインターフェースを提供してくれる画像の抽象であるImage Viewを作成する必要があります。
Image Viewは画像へのアクセス方法を提供するので、どんな目的で画像を使うのか、そもそも扱うのはどんな画像なのかなどを設定して作成してあげます。
10. レンダリングのときの画像の種類とか扱い方を記述するRenderPassを作成する
次に、デバイスにも何をしてほしいのかを事前に教えてあげる必要があります。デバイスに対してやってほしいこととは、当然画像を生成することです。
これを伝えるための形式として私たちはRender Passとよばれるものを作成する必要があります。Render Passでは、必要な作業Subpassとよび、その結果生成されるものや利用するものをAttachment、それらの作業の依存性をDependencyと定義しています。
これらの情報をしっかりと設定してRender Passを作成することが、効率の良いレンダリングを行うための前準備としてVulkanでは必要となるのです。ここでは、Render Passを用意するにとどめ、この後デバイスへの命令を作成する際に含めることになります。
11. 画像の抽象にアクセスするためにデバイスにFrameBufferを確保しておく
Render Passからは利用するリソースがAttachmentとして見えていますが、私たちが用意したレンダリングに使用してほしいものはImage Viewです。このままでは、お互いの意思疎通の不足で画像生成が上手く動いてくれません。
そこで、Render Passの利用するAttachmentと用意したSwap Chainから取り出したImage Viewを結び付けてくれる存在としてFrame Bufferを作成しておく必要があります。
12. 最低限必要な頂点シェーダーとフラグメントシェーダを作成する
Vulkanで使うデバイス、描画対象、デバイスへの命令方法が決定したので、いよいよ具体的な画像生成の中身を記述していきます。なので皆さんどうか起きてください!
昨今の3D画像の生成には、もっぱらPipelineというモデルが用いられています。まるで流れ作業のように、3D空間上の頂点から一枚の画像の作成までを、いくつかの層を通して行っていきます。Vulkanでは明示的にこのPipelineを作る必要があるため、ここからはそのPipelineの作成に取り掛かります。
Pipelineの中の層のいくつかは勝手に処理を行ってくれるのですが、私たちがプログラムを書いて完全に制御できるモノもあります。ここで述べる頂点シェーダーとフラグメントシェーダは、後者を制御するプログラムです。
シェーダーはPipelineから流れて来た情報をもとに、次の層に渡すための出力を作成するプログラムを記述します。ここではその詳細は省きますが、実際に描画される画像に対して大きな影響力を持っています。
13. シェーダーをコンパイルし、Vulaknで扱えるバイナリの形式にしておく
従来のグラフィックライブラリでは、シェーダーはそれぞれ特有のテキスト形式の言語で記述する必要がありました。しかし、VulkanではSPIR-Vと呼ばれるバイナリ形式のシェーダーを利用します。
え、バイナリを書くのですか?と思った方はご安心ください。Vulkanでは、既存のシェーダー言語を用いて書かれたシェーダーをコンパイルしたものを用います。
このひと手間によって、何と私たちはコンパイルエラーという素晴らしい啓示をいただくことができ、Vulkanもより厳密でわかりやすい形でシェーダーを読み込むことになるのです。
14. バイナリ形式のシェーダーを読み込み、レンダラーに組み込むモジュールを作成する
バイナリ形式のシェーダーを用意したら、それを昔ながらのファイル読み込みで持ってきて、Vulkanで利用できるShader Moduleという形でまとめておきます。
さらに、Pipelineの層の一つとして機能してもらうためにShader StageというオブジェクトをShader Moduleを使って作成します。
15. シェーダーで記述出来ない部分のパイプラインの処理に対してオプションを設定し、パイプラインを作成する
先述した通り、Pipelineには他にも多くの層があり、その中にはシェーダーによって制御できないものもあります。私たちは、それらの層が勝手に画像を生成していくのを、指を咥えて見ているしかないのでしょうか?そんなことはありません。
Vulkanでは、Pipeline作成の際に、様々なオプションを指定して画像生成の過程を制御できます。というか、オプションを設定しないとPipelineが作成できません。
オプションを指定するのは、頂点の入力、レンダリングを行うFrame Buffer上の領域、ラスタライジング、マルチサンプリング、カラーブレンディングなど多岐にわたります。
これらのオプションを指定した後、14.で作成したShader Stageを含めてPipelineを作成します。
16. デバイス間でメモリを転送するための命令をデバイスに保持するためのBufferを確保するためのプールを作成する
Pipelineもできたし、もう終盤かな!と思ったそこのあなた、安心してください、まだ中盤です。
ここからは今まで準備したものを使ってデバイスを動かすための、命令そのものを作成していきます。この命令はCommand Bufferに置かれることになります。そして、このCommand Bufferのための領域は、Command Buffer用のプール領域から再利用可能な状態で提供されます。
ここでは、Command Bufferの中でも、この後に必要になるデバイス間でのメモリ転送に利用するCommand Bufferのためのプール領域を作成します。
17. デバイス間でメモリを転送するための命令をデバイスに保持するためのBufferを確保する
デバイス間でのメモリ転送に利用するCommand Bufferのためのプール領域を作ったので、そこから切り出すような形でCommand Bufferを確保します。このCommand Bufferはこの後のステップで利用します。
18. 頂点の情報をデバイスに保持するためのBufferを確保する
3Dの画像を生成するための元になる情報として、3つの頂点を持つポリゴンが使われていることは皆さんご存じだと思います。画像生成に利用するためのもっとも基礎的な情報は頂点です。
プログラムの中で保持している頂点の情報は、基本的にCPUが扱うメモリ上に存在します。しかし、デバイス上で行われる画像生成の中でそれらを利用するためには、デバイスのメモリにその情報を移す必要があります。
その一歩目として、まずはデバイスにメモリ領域を確保します。ここでは、メモリ領域の大きさが必要になるため、あらかじめ頂点を表すデータ構造を作っておく必要があります。このデータ構造は、頂点シェーダーで用いているものと同じになるようにします。
19. StagingBufferを経由して頂点の情報をデバイスに送信する
デバイスには複数のメモリ領域があり、Vulkanはそれらを明確に区別して扱うことが出来ます。これによって私たちはより効率よく画像の生成が行われるように、メモリレベルで最適化を行うことが可能になります。
頂点のデータも、利用に適したメモリ領域が存在し、そこにデータを送信することが求められます。そして、デバイスにとって扱いやすいその領域は、CPUから直接アクセスできるところにはないのです。
CPUにアクセスできない領域に頂点データを送るために、私たちはデバイス上に中間的な領域を作る必要があります。そこは、CPUが扱うメモリ領域と同期することでデバイスの中でもCPUからアクセス可能な領域です。その中間領域はStaging Bufferと呼ばれます。
通常のメモリコピーと同じ要領でStaging Bufferに頂点のデータを渡した後、17で作成した"デバイス間でメモリを転送する"ためのCommand Bufferに、Staging Bufferから18.で作成した領域にデータをコピーする命令を置きます。そして、みなさんお忘れかもしれませんが、6.で作成したキューにその命令を実行する指示を積んでおきます。これによって本当に置きたかったメモリ上に頂点のデータを置くことが出来ます。
20. 頂点を結んで三角形を形成するための順番をデバイスに保持するためのBufferを確保する
頂点のデータを送ったら、次にするべきなのは頂点のインデックスを送る処理です。実は、18.~19.でデバイスに送ったのは頂点そのもののデータなので、それをどのような順番で結んでポリゴンを作るのかについてはまだ何もデバイスに伝えられていません。多くのグラフィックライブラリでは、頂点を結ぶ順番をインデックスデータとしてデバイスに送ります。
そこで、頂点データを送った時と同じようにまずはデバイスに専用のメモリ領域を確保します。
21. 頂点の結んで三角形を形成するための順番をデバイスに送信する
インデックスデータもまた、デバイスの処理から高速にアクセスできる領域に置く必要があるため、Staging bufferを経由してデバイスに送ります、
22. 三角形の頂点の位置から画面上での位置を求めるための変換行列を保持するためのBufferを確保するためのプールを作成する
通常の3Dグラフィックス用の頂点データは、画面上の座標ではなく、3D空間上の座標のものになっているため、画像生成の中で変換を行う必要があります。
このような変換のための情報を含む、同じタイミングの画像生成の中でグローバルに用いられる変数は、Descriptorと呼ばれます。
このDescriptorを置くためのデバイス上のメモリ領域は、直接作成することはできません。そのため、Command Bufferと同じようにまずはプール領域を作成します。
23. 三角形の頂点の位置から画面上での位置を求めるための変換行列を保持するためのBufferを確保する
プール領域を作成したら、そこからDescriptor用の領域を確保します。これをDescriptor Setと呼びます。
Descriptor Setを作成するためには、そこにどのようにデータが格納されているのかを示すレイアウト情報を含める必要があります。このデータの格納方法は、使用するシェーダーで定義されるものと同じにする必要があります。
24. 三角形の頂点の位置から画面上での位置を求めるための変換行列をデバイスに送信する
データを格納する領域が用意できたので、そこにCPUのメモリ上のデータをコピーしていきます。
このとき、上で例に挙げたように、3D空間とカメラ空間の変換のためのデータなど、頂点データやインデックスデータと比べて変化することの多いデータをDescriptor Setに送る場合は、Staging Bufferに送るときのようにCPUから直接デバイスにデータを送る方が効率的な場合があります。
25. デバイスに描画を行う命令を保持するBufferを確保するためのプールを作成する
いよいよ画像の生成に必要なパーツの用意は終わりました。ここからは、私たちがスイッチを押すだけです。
それでは、スイッチを押す、つまりデバイスに描画をしてもらうための命令を格納するCommand Bufferを確保するためのプール領域を作成します。はい、最後までこんな感じです。
手順としては、16.とほとんど変わりません。
26. デバイスに描画を行う命令を保持するBufferを確保し、三角形の情報を保持するBufferと結びつける
デバイスの描画命令を行うCommand Bufferには、今までに用意した様々な情報を紐づけていきます。
ここで、Render Pass、Frame Buffer、Pipeline、頂点データにインデックスデータ、Descripto setなど、今まで用意してきたオブジェクトたちが大集合して、一気にCommand Bufferと紐づけられます。激熱。
27. デバイスでの描画とCPUの処理を同期するためのオブジェクトを作成する
描画命令を持つCommand Bufferを用意したら、早速動かしたくなりますが、もう少しだけ下準備をしていきます。
それは、CPU上でデバイスに描画命令を送信する際に、その処理を最後まで待つことなく非同期にCPU上での処理を行っていくために必要なオブジェクトの用意です。
Vulkanでは非同期処理を行うためのオブジェクトとして、SemaphoreやFenceがあります。これらのオブジェクトを用いることでデバイスの処理が完了しているかどうかを確認することが出来ます。これによって、画像の生成が終わったことを確認してから画面への描画命令を出すなどの非同期処理が可能になります。
28. デバイスに描画を行う命令を実行するように知らせる
用意したCommand Bufferを利用して、ついに画像の生成を行います。
画像生成命令用のキューに、Command Bufferや27.で用意したセマフォを渡して、非同期に画像生成を始めます。
29. デバイスに描画を行った結果を画面に表示するように知らせる
そして最後に、Swap Chain上の画像をウインドウに表示するための描画命令を送り、生成した画像を表示させます。
画像生成の命令に渡したセマフォから信号が送られてくることを確認するように設定し、画面表示命令用のキューに画面表示を行う命令を積みます。この命令は、Swap Chain上の画像のインデックスのみを指定し、特別なCommand Bufferは必要ありません。
Conclusion
これらの29ステップを踏むことで、Vulkanで生成した画像の画面への表示が達成されます。壮大な道のようにも思えますが、もともと3DCGの生成には、これだけの工程が必要であることは、他のグラフィックライブラリを用いていたとしても変わらない事実です。
Vulkanは他のグラフィックライブラリでは隠蔽してしまっているような処理を必ず必要なものとすることで、時にはより効率的なレンダリングを行うことが出来るようになり、時には私たちとの認識の齟齬が生まれないような仕事をしてくれます。
これは、今までにない大きなメリットであり、そしてVulkanの最大の欠点でもあります。
Vulkanを利用する、または学ぶ機会があった場合、あまりの複雑さに圧倒されてしまうことがあるかもしれませんが、その時は、この記事も参考にしていただきつつ、Vulkanの持つ現代的なグラフィックスのモデルやAPI設計の意図を感じながら、ぜひとも楽しんで取り組んでいただければと思います。
ここまでついてきてくれた方、スクロールしてくれた方、お疲れ様です。
いよいよ最終日の明日は53代幹事長であるwびらきくんの記事です。みなさん、チャンネルはそのままで。