Vulkanで困るところ

こんばんわPocolです。
Vulkanについてある程度知見が増えてきたので,ここでちょっとまとめておこうと思います。

 
 

Vulkanに適しているもの・適していないもの

<Vulkan移植を避けるべきもの>
・OpenGL → Vulkanへの移植案件。
・Direct3D 11 → Vulkanへの移植案件。
・ゲームエンジンやライブラリなどマルチプラットフォーム対応されているプログラムのVulkan対応

<Vulkan移植してもいいかなという条件>
・非マルチプラットフォーム(対応APIがVulkanのみ)
・ブレンドステートやラスタライザ―ステート,シェーダなどの組み合わせが事前にすべて分かるアプリケーション。
・CPUがボトルネック。
・コピーコマンドをほとんど使わない(動画テクスチャみたいなものを使用しない)
・D3D12で動くプログラム

まず,恐らくゲーム業界であれば「LinuxやAndroidでどうしてもCPUボトルネックで速くしたい!」という高いモチベーションが無い限りは,頑張り損のくたびれ儲けになるのでVulkan対応は見送った方が良いです。

そもそも,VulkanはCPUのボトルネックを解消するためのものなので,スマホゲームのようにテクスチャをいっぱい使ったりとか,GPUボトルネックになっているものはVulkanを使ったところで,そもそも速くなることはありません。むしろVulkanを使うための処理が複雑になるので,かえって遅くなるぐらいに考えておいた方が良いです。

 
 

それは勘違いです!

ネット等でよく見る勘違いが「VulkanとDirect3D 12って大体同じなんでしょ?」という意見。
これに対する回答は,「できることは大体同じだけど,設計や実装は全く別物になる」です。
もし,VulkanとD3D12を同じようにラップ出来ると考えている人がいたら,それは勘違いなので考えをあたらためた方が良いです。
Vulkanが入ることで,一気に設計とか仕様が荒れます。Vulkanだけは切り離して考えた方が設計実装は綺麗にまとめやすいです。

 
 

何がこまる?

そもそもVulkanとD3D12では思想が違う。
基本的にVulkanはGPUを作っている人が主導で仕様を決めている感じ。
そのため,「ドライバーのオーバーヘッド少なくしたいのであれば全部よこせ!」という思想。その場で,「ちょっとここだけを直したいんだよねぇ。」という場面でも「全部よこせ!」という思想なので,1個だけ変更するのに3個とか5個とか全部データを渡さないと動作しないです。さらにむかつくのは「全部よこせ!」と言って渡したデータをVulkan内でユーザーが取得できるようになっていないので,Direct3DでいうところのGetDesc()やOpenGLでいうところのglGetIntegerv()みたいな取得関数はほぼ存在しないので,これらを多用するライブラリやエンジンの移植作業は間違いなく詰みます。じゃ,どうすればいいかというとDirect3DやOpenGLがやっていた仕組みを自分で実装するほかにありません。「それだけでも面倒そう…」って思いません?
基本的にVulkan専用のハードとか作っている会社は無いと思うので,Vulkanの仕様を決めている人たちは,多少仕様がおかしかったり,糞使いづらくても自分たちのビジネスに直接打撃を受ける人は少ないのではないでしょうか?

一方,Direct3Dの仕様を策定しているMicrosoftは自分たちのビジネスに直接打撃を受けます。そのため,ある程度開発者側の要望をちゃんと聞いてくれるせいか,使いづらいとは言ってもVulkan程ではありません。

 
 

具体的には?

じゃ,具体的にどこが使いづらいのかを述べていきましょう。

まず定数バッファを使る場合。
<Direct3D 12の場合>
関連するオブジェクトは
・ID3D12DescriptorHeap
・ID3D12RootSignature
・D3D12_GPU_DESCRIPTOR_HANDLE
・D3D12_DESCRIPTOR_RANGE
・D3D12_ROOT_PARAMTER
など。
作成手順は,
(1) ID3D12DescriptorHeapを作る
(2) ID3D12DescriptorHeapからD3D12_GPU_DESCRIPTOR_HANDLEを得る。
(3) D3D12_DESCRIPTOR_RANGEでシェーダのレジスタ番号などを設定する。この時に,Comオブジェクトは必要ない。
(4) D3D12_DESCRIPTOR_RANGEを元にD3D12_ROOT_PARAMETERを設定する。この時にComオブジェクトは必要ない。(この時点でレイアウトが決まる)
(5) D3D12_ROOT_PARAMTERをD3D12_ROOT_SIGNATURE_DESCに設定して,シリアライズして,ID3DBlobからID3D12RootSignatureを作る。
(6) D3D12_GRAPHICS_PIPELINE_STATE_DESC に ID3D12RootSignatureを設定して,ID3D12PipelineStateを作る。

つまり,ディスクリプタを作ってから,レイアウトを決めるという流れ。ディスクリプタを作るのにID3D12Resourceが必要ない。
レイアウトはディスクリプタを作ってから後でどうにでも出来る。

<Vulkanの場合>
関連するオブジェクトは
・VkDescriptorSetLayoutBinding
・VkDescriptorSetLayout
・VkDescriptorPool
・VkDescriptorSetAllocateInfo
・VkDescriptorBufferInfo
・VkWriteDescriptorSet
など。
作成手順は,
(1) VkDescriptorPoolSizeでディスクリプタ数と最大ディスクリプタセット数を決めて,VkDescriptorPoolを作る。
(2) VkDescriptorSetLayoutBindingでシェーダのレジスタ番号などを設定する。(この時点でレイアウトが決まる)
(3) VkDescriptorSetLayoutBindingで設定した情報を元に,VkDescriptorSetLayoutを生成する。
(4) VkDescriptorSetAllocateInfoでアロケートするためにVkDescriptorSetLayoutを設定し,VkDescriptorSetを生成する。
(5) VkDescriptorBufferInfoにVkBufferを設定する。
(6) VkDescriptorBufferInfoとVkDescriptorSetを元に更新処理を実行し,使用可能状態にする。

つまり,レイアウトを決めてから,ディスクリプタを作るという流れ。ディスクリプタセットを作った後で,さらに描画コマンド作成前にVkBufferが必要。
後でレイアウトは変更できない。オブジェクト生成時に決まっている必要がある。

更に言うと,(6)でやる更新処理ですが,メソッドでいうとvkUpdateDescriptorSets()になるのですが,このメソッドは制限があります。どのような制限かというとレンダーパス実行中は呼び出し禁止というものです。要するにフレームバッファをバインドしてしまった後は呼び出せません。ここが,OpenGLやDirect3D11の移植等で困る点です。ふつうはレンダーターゲット設定して,シェーダ設定して,それから定数バッファを設定という流れになると思うのですが,その流れがVulkanではNGです。
この辺のVulkan特有の制限をしらないと\(^o^)/状態になります。

続いてパイプラインステートを作成する場合。
<Direct3D 12の場合>
D3D12_GRAPHICS_PIPELINE_STATE_DESC構造体に設定する項目は次のようになります。
・ID3D12RoogSignature* pRootSignature;
・D3D12_SHADER_BYTE_CODE VS;
・D3D12_SHADER_BYTE_CODE PS;
・D3D12_SHADER_BYTE_CODE DS;
・D3D12_SHADER_BYTE_CODE HS;
・D3D12_SHADER_BYTE_CODE GS;
・D3D12_STREAM_OUTPUT_DESC StreamOutput;
・D3D12_BLEND_DESC BlendState;
・UINT SampleMask;
・D3D12_RASTERIZER_DESC RasterizerState;
・D3D12_DEPTH_STENCIL_DESC DepthStencilState;
・D3D12_INPUT_LAYOUT_DESC InputLayout;
・D3D12_INPUT_BIFFER_STRIP_CUT_VALUE IBStripCutValue;
・D3D12_PRIMITIVE_TOPOLOGY_TYPE PrimitiveTopologyType;
・UINT NumRenderTargets;
・DXGI_FORMAT RTVFormats[8];
・DXGI_FORMAT DSVFormat;
・DXGI_SAMPLE_DESC SampleDesc;
・UINT NodeMask;
・D3D12_CACHE_PIPELINE_STATE CachedPSO;
・D3D12_PIPELINE_STATE_FLAGS Flags;

設定に必要なComオブジェクトはID3D12RootSignatureのみ。シェーダはバッファポインタとサイズのみ。
Direct3D12の場合は,ID3D12RootSignatureさえ作っておけば,あとはその場で値を設定するだけでよいです。依存オブジェクトは1つのみ。

<Vulkanの場合>
VkGraphicsPipelineCreateInfo構造体に設定する項目は次のようになります。
・VkStructureType sType;
・const void* pNext;
・VkPipelineCreateFlags flags;
・uint32_t stageCount;
・const VkPipelineShaderStageCreateInfo* pStages;
・const VkPipelineVertexInputStateCreateInfo* pVertexInputState;
・const VkPipelineInputAssemblyStateCreateInfo* pInputAssemblyState;
・const VkPipelineTessellationStateCreateInfo* pTessellationState;
・const VkPipelineViewportStateCreateInfo* pViewportState;
・const VkPipelineRasterizationStateCreateInfo* pRasterizationState;
・const VkPipelineMultisampleStateCreateInfo* pMultisampleState;
・const VkPipelineDepthStencilStateCreateInfo* pDepthStencilState;
・const VkPipelineColorBlendStateCreateInfo* pColorBlendState;
・const VkPlineDynamicStateCreateInfo* pDynamicState;
・VkPipelineLayout layout;
・VkRenderPass renderPass;
・uint32_t subpass;
・VkPipeline basePipelineHandle;
・int32_t basePipelineIndex;

設定には,VkRenderPass, VkPipelineLayoutのオブジェクトが必要。
また,D3D12には無いビューポートとシザー矩形の情報が必要。
シェーダは バッファポインタとサイズとエントリーポイント名が必要。

Vulkanの場合は,2つのオブジェクトに依存します。その場で,値設定できないものが1つ多いです。さらにシェーダもメインエントリーポイント名が必要で,”main”に固定しているのであれば,問題ないですが,そうではない場合はDirect3Dに無い情報が必要になるので,シェーダコンバーターなど自前で作っているツールがある場合には修正が必要になります。さらにパイプラインステートにビューポートとシザー矩形があるので,動的に変更しない場合は必ず渡す必要があります。Vulkanは依存がややこしいです。

つづいて,フレームバッファ。
<Direct3D 12の場合>
ID3D12GraphicsCommandList::OMSetRenderTargets()でカラーバッファと深度ステンシルバッファの組み合わせを決定する。引数を変えれば,その場で組み換えが可能。

<Vulkanの場合>
vkFramebufferとしてカラーバッファと深度ステンシルバッファの組み合わせを決定する。vkFramebufferを作成した後で,組み合わせを変更することは不可能。

つまり,ColorA, ColorB, Depthという3枚のターゲットがあった場合に,D3D12の場合は,
SetRenderTargets(1, &ColorA, Depth);
SetRenderTargets(1, &ColorB, Depth);
SetRenderTargets(2, Colors, Depth);
SetRenderTargets(1, &ColorA, nullptr);
SetRenderTargets(1, &ColorB, nullptr);
SetRenderTargets(0, nullptr, Depth);
という感じで組み合わせをその場で作れるが,Vulkanの場合は
vkFramebuffer framebufferA;
vkFramebuffer framebufferB;
vkFramebuffer framebufferC;
vkFramebuffer framebufferD;
vkFramebuffer framebufferE;
vkFramebuffer framebufferF;
のように全組み合わせを事前に用意しておく必要があります。そして,このvkFramebufferが簡単に作れるのであればよいのですが,設定項目が多く色々と面倒です。
さらに,フレームバッファのクリア処理ですがD3Dの場合はClearRenderTarget()やClearDepthStencil()のようにターゲットを設定した後で,クリアすることが可能です。Vulkanにも一応これと同じことをするために,vkCmdClearAttachments()メソッドが用意されていますが,このメソッドを呼び出すとパフォーマンス警告がVulkan側から出力されるようになります。つまりパフォーマンスを気にするのであれば,D3Dと同じことが出来ません。vkCmdClearColorImage(), vkCmdClearDepthStencilImage()というメソッドもあるのですが,これらの2つのメソッドはレンダーパス実行中。つまり,フレームバッファをバインドした後は呼び出しできないようというVulkanの仕様が定めれています。

つづいて,ディスクリプタセットやディスクリプタテーブルについて。
ID3D12GraphicsCommandList::SetGraphicsRootConstantBufferView() とか, ID3D12GraphicsCommandList::SetGraphicsRootShaderResourceView() みたいなAPIはVulkanには存在しません。PushConstantがかろうじて使えるかなという感じですが,これも小さな定数バッファ用なので大きな定数バッファとして使うことができません。VulkanはD3D12でいう所のディスクリプタテーブル単位でしか生成することが出来ない。よってD3D12とVulkanをサポートするのであれば,ライブラリやエンジンでSetGraphicsRootXXXX系に当たる関数はVulkanのせいでサポートできなくなります。さらに,ディスクリプタセットの更新は,フレームバッファバインド中は使用禁止という仕様があるため,Direct3D12のように書いている最中にちょっと変えたいなぁーみたいなことは出来ないので,アプリのフローも思いっきり変わります。さらに,D3Dの場合はヒープとして大きい定数バッファを256バイトアライメントで確保しておいて,内部を細かく分けて使うみたいなことができますが,Vulkanはメモリアライメントの要求はないようですが,内部を細かく分けて使うときにバッファオフセットを使用すると思うのですが,このオフセットに制限がありGPUによるとおもいますが,自分のGPUでは256byteアライメントされていないといけません。もしD3D12とVulkanを両方サポートするのであれば,必ず256byteアライメントでメモリもオフセットも確保されるように作らないといけなくなります。そのため,小さな定数バッファが多いとメモリの無駄が出来やすく使用効率がちょっと悪くなりそうです。

次は,プリミティブトポロジー。
Direct3Dでは,IASetPrimitiveTopology()のようなメソッドで,コマンド中にプリミティブトポロジーを変更することが出来ますが,Vulkanでは対応するAPIは存在しません。プリミティブトポロジーの設定は,パイプラインステートで設定します。

他にもD3D12よりも複雑なメモリバリアの設定や,色々と話す内容はありますが長くなるので,この辺で止めておきましょう。

 
 

そんな大変なの対応している会社あるの?

一応存在します。
Unreal Engineで有名なEpic Gamesと,Doomを作っているid Software。Unityは正式サポートされたんですかね?9月の時点ではVulkan対応はPreview版だった気がします。

Unreal EngineとDoomで対応したときのその大変さが,スライドで公開されているので一度目を通されると良いです。

https://www.khronos.org/assets/uploads/developers/library/2016-siggraph/3D-BOF-SIGGRAPH_Jul16.pdf

スライドを見ると分かると思うのですが,あのアンリアルエンジンの開発者でさえ,最初に対応したバージョンは辛うじて20FPS出せたとの記述がみられます。その次のバージョンでは,最適化して30FPS出たそうです。
大事なことなので声を大にして言っておきましょう。”超一流のグラフィックスエンジニアがVulkanを最初に対応したら全然パフォーマンスが出なかった。”ということです。
あなたはゲームエンジンのエンジニアですか?あなたが超一流だったとしても,Vulkanに対応しても全然パフォーマンスが出ない恐れがあります。Vulkan対応はもう一度考え直した方が良いのではないでしょうか?

 
 

これらを踏まえて

 以上を踏まえて,私が出した結論は「Vulkanは絶対に触るべきではない」です。赤本がもうそろそろ出ますが,少なくとも大幅な仕様変更があるまでは対応コストがあまりに高く,得られるリターンは少ないので「Vulkan触れるとちょっとかっこいいかも…。」とか「最新テクノロジーを頑張っていることをアピールするのに使いたい」ぐらいのモチベーションなら,絶対に痛い目に合うと思うのでお勧めはできません。「いや,もうVulkan対応しないとパフォーマンスでなくてゲームリリースできない」とか逃げることができない状況がやって来ることが予想されるならば対応すべきでしょう。Vulkanは既に述べたように,「事前に全て揃っている」ことが前提のAPIなので,アプリ側でオブジェクトなど作らねばいけないものは全て作っておく必要があります。そのため,メモリは結構食われるんじゃないでしょうか?モバイルや組み込み機器等の省メモリが要求される環境では,Vulkanは完全に悪人と化す恐れがあります。Vulkan使うな!とは言いませんが… 使用する場合は前もってスケジュールが伸びる可能性があること,対応できても速くならない可能性があること,様々なリスクを取る覚悟を決めてから作業に着手した方が良いかと思います。
 少なくともゲーム業界でVulkanを使う人はあまり増える見込みは無いように思えます。自分たちが普段触っているコンソールゲームのグラフィックスAPIの方がより低レベルで扱いやすいですから。

Leave a Reply

* が付いている項目は、必須項目です!

*

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)

Trackback URL