この記事は Shader modules - Vulkan Tutorial の日本語訳です。
Shader modules
それまでのAPIと違い、Vulkanでのシェーダコードは、GLSL
やHLSL
のような人間が読める文法とは対象的な、バイトコードフォーマットで指定する必要があります。このバイトコードフォーマットはSPIR-V
と呼ばれ、VulkanとOpenCL(どちらもKhronosのAPIです)両方から使われるように設計されています。グラフィックスシェーダとコンピュートシェーダを書くのに使うことができるフォーマットですが、このチュートリアルではVulkanのグラフィックスパイプラインで使われるシェーダにフォーカスします。
バイトコードフォーマットを使うことの利点は、シェーダコードをネイティブコードに変換するためにGPUベンダが書くコンパイラが、大幅に単純になることです。GLSLのような人間が読める構文では、いくつかのGPUベンダが規格をより柔軟に解釈するということが過去にありました。これらのベンダのうちの1つのGPU上で単純でないシェーダを書いたとき、他のベンダのドライバでは文法エラーで拒否されたり、もっと悪い場合はコンパイラのバグによって異なる動作をするという危険性がありました。SPIR-Vのような単純なバイトコードフォーマットでは、そのようなことは回避されているはずです。
しかし、このバイトコードを手動で書く必要があるというわけではありません。Khronosは、GLSLからSPIR-Vにコンパイルする、ベンダ独立な独自のコンパイラをリリースしています。このコンパイラは、あなたのシェーダコードが完全に規格に準拠していることを検証し、プログラムに同梱できるSPIR-Vバイナリを生成するように設計されています。また、このコンパイラをライブラリとしてインクルードし、実行時にSPIR-Vを生成することもできますが、このチュートリアルでは行いません。このコンパイラをglslangValidator.exe
として直接使うこともできますが、私たちはGoogleのglslc.exe
を代わりに使います。glslc
の利点は、GCCやClangのような有名なコンパイラと同じパラメータフォーマットを使えることと、includesのような追加の機能を含んでいることです。これらは両方ともVulkan SDKに既に含まれているので、追加で何かダウンロードする必要はありません。
GLSLはCスタイルの文法のシェーダ言語です。GLSLで書かれたプログラムには、全てのオブジェクトから呼び出されるmain
関数があります。入力に引数、出力に戻り値を使う代わりに、GLSLでは入力と出力を処理するためにグローバル変数を使います。この言語には、組み込みのベクトルと行列のような、グラフィックスプログラミングを手助けするたくさんの機能が含まれています。外積、行列とベクトルの乗算、ベクトルの反射などの操作のための関数が含まれています。ベクトル型はvec
と要素数を表す数字と共に呼ばれます。例えば、3次元の位置はvec3
に格納されます。.x
のようにメンバを通して単一の要素にアクセスすることもできますし、複数の要素から新しいベクトルを作成することもできます。例えば、vec3(1.0, 2.0, 3.0).xy
という式の結果はvec2
になります。ベクトルのコンストラクタは、ベクトルとスカラー値の組み合わせを取ることもできます。例えば、vec3
はvec3(vec2(1.0, 2.0), 3.0)
のようにして生成することができます。
前章で述べたように、画面に三角形を描画するためには頂点シェーダとフラグメントシェーダを書く必要があります。次の2節ではそれらのGLSLコードを説明し、その後、2つのSPIR-Vバイナリを生成し、それらをプログラムにロードする方法について見ていきます。
Vertex shader
頂点シェーダは入ってくる頂点それぞれを処理します。ワールド位置、色、法線、テクスチャ座標などのようなアトリビュートを入力として受け取ります。出力はクリップ座標系における位置と、色やテクスチャ座標のようなフラグメントシェーダに渡す必要があるアトリビュートです。これらの値は、スムーズなグラデーションを生成するために、ラスタライザーによってフラグメント間で補間されます。
クリップ座標(clip coordinate)とは頂点シェーダからの4次元ベクトルで、その後、ベクトル全体を最後の要素で割ることによって正規化デバイス座標(normalized device coordinate)に変換されます。正規化デバイス座標は、フレームバッファを[-1, 1] × [-1, 1]座標系にマッピングする同次座標で、次のようなものです。
コンピュータグラフィックスに手を出したことがあるなら、これらには既に精通していることでしょう。もしOpenGLを使ったことがあるなら、Y座標の符号が反転していることに気がついたかもしれません。Z座標はDirect3Dと同じ、0から1までの範囲を使います。
私たちの最初の三角形は、いかなる変換も適用しないつもりなので、3つの頂点の位置を正規化デバイス座標で直接指定して、以下の形を作成します。
頂点シェーダで最後の要素を1
に設定してクリップ座標として出力することにより、正規化デバイス座標系で直接出力することができます。この方法だと、クリップ座標系から正規化デバイス座標系へと変換するための除算で何も変化しません。
通常、これらの座標は頂点バッファに格納されますが、Vulkanで頂点バッファを作成し、データを設定する方法は自明ではありません。そのため、三角形の画面への表示を達成するまで、それを後回しにすることにしました。それまでの間、少しオーソドックスでないことをします。頂点シェーダの中に直接、座標を含んでしまいます。コードは次のようになります。
#version 450
vec2 positions[3] = vec2[](
vec2(0.0, -0.5),
vec2(0.5, 0.5),
vec2(-0.5, 0.5)
);
void main() {
gl_Position = vec4(positions[gl_VertexIndex], 0.0, 1.0);
}
main
関数は全ての頂点に対して呼び出されます。組み込み変数のgl_VertexIndex
は、現在の頂点のインデックスを格納しています。これは通常、頂点バッファ内のインデックスですが、今回のケースでは、頂点データがハードコーディングされた配列のインデックスです。それぞれの頂点の位置はシェーダ内の配列定数からアクセスされ、ダミーのz
とw
要素と組み合わさってクリップ座標での位置を生成します。組み込み変数のgl_Position
は出力として機能します。
Fragment shader
頂点シェーダからの位置で形作られた三角形は画面中の領域をフラグメントで満たします。フラグメントシェーダはこれらのフラグメントに対して呼び出され、フレームバッファ用の色とデプス値を生成します。三角形全体に対して赤色を出力する単純なフラグメントシェーダは以下のようになります。
#version 450
layout(location = 0) out vec4 outColor;
void main() {
outColor = vec4(1.0, 0.0, 0.0, 1.0);
}
頂点シェーダのmain
関数が頂点ごとに呼び出されるように、フラグメントシェーダのmain
関数はフラグメントごとに呼び出されます。GLSLでの色は4要素のベクトルで、[0, 1]の範囲のR、G、B、アルファのチャンネルを持ちます。頂点シェーダのgl_Position
とは異なり、現在のフラグメントの色を出力するための組み込み変数はありません。自身の出力変数を、フレームバッファごとに指定しなければいけません。layout(location = 0)
修飾子でフレームバッファのインデックスを指定します。赤色はこのoutColor
変数に書き込まれ、それはインデックス0
にある最初の(そして唯一の)フレームバッファとリンクされます。
Per-vertex colors
三角形全体を赤色にするのはそれほど面白くないので、次のような見た目のほうがもっと素敵になるのではないでしょうか?
これを実現するためには、両方のシェーダにいくつかの変更をする必要があります。最初に、3つの頂点それぞれに個別の色を指定する必要があります。頂点シェーダは、位置のときと同じように、色の配列を含むようになります。
vec3 colors[3] = vec3[](
vec3(1.0, 0.0, 0.0),
vec3(0.0, 1.0, 0.0),
vec3(0.0, 0.0, 1.0)
);
あとは、これらの頂点ごとの色をフラグメントシェーダにそのまま渡すだけでよく、フレームバッファにはそれらの補間された値が出力されます。頂点シェーダに色の出力を追加し、main
関数でそれに書き込みます。
layout(location = 0) out vec3 fragColor;
void main() {
gl_Position = vec4(positions[gl_VertexIndex], 0.0, 1.0);
fragColor = colors[gl_VertexIndex];
}
次に、マッチする入力をフラグメントシェーダに追加する必要があります。
layout(location = 0) in vec3 fragColor;
void main() {
outColor = vec4(fragColor, 1.0);
}
入力変数は必ずしも同じ名前を使う必要はなく、これらはlocation
ディレクティブで指定されるインデックスを使ってリンクされます。main
関数はアルファ値と共に色を出力するように変更されました。上の画像で見た通り、fragColor
の値は3つの頂点間のフラグメントに対して自動的に補間され、結果的になめらかなグラデーションとなります。
Compiling the shaders
あなたのプロジェクトのルートディレクトリにshaders
という名前のディレクトリを作成し、このディレクトリ内にshader.vert
というファイル名で頂点シェーダを、shader.frag
というファイル名でフラグメントシェーダを格納してください。GLSLのシェーダに公式な拡張子はありませんが、これら2つは識別するためによく使われる拡張子です。
shader.vert
の中身は次のようになります。
#version 450
layout(location = 0) out vec3 fragColor;
vec2 positions[3] = vec2[](
vec2(0.0, -0.5),
vec2(0.5, 0.5),
vec2(-0.5, 0.5)
);
vec3 colors[3] = vec3[](
vec3(1.0, 0.0, 0.0),
vec3(0.0, 1.0, 0.0),
vec3(0.0, 0.0, 1.0)
);
void main() {
gl_Position = vec4(positions[gl_VertexIndex], 0.0, 1.0);
fragColor = colors[gl_VertexIndex];
}
そしてshader.frag
の中身は次のようになります。
#version 450
layout(location = 0) in vec3 fragColor;
layout(location = 0) out vec4 outColor;
void main() {
outColor = vec4(fragColor, 1.0);
}
では、これらをglslc
プログラムを使って、SPIR-Vバイトコードにコンパイルしていきます。
Windows
次のような中身のcompile.bat
ファイルを作成してください。
C:/VulkanSDK/x.x.x.x/Bin32/glslc.exe shader.vert -o vert.spv
C:/VulkanSDK/x.x.x.x/Bin32/glslc.exe shader.frag -o frag.spv
pause
glslc.exe
へのパスはVulkan SDKをインストールしたパスに置き換えてください。実行するにはファイルをダブルクリックします。
Linux
次のような中身のcompile.sh
ファイルを作成してください。
/home/user/VulkanSDK/x.x.x.x/x86_64/bin/glslc shader.vert -o vert.spv
/home/user/VulkanSDK/x.x.x.x/x86_64/bin/glslc shader.frag -o frag.spv
glslc
へのパスはVulkan SDKをインストールしたパスに置き換えてください。chmod +x compile.sh
でスクリプトを実行可能にしてから実行してください。
End of platform-specific instructions
これら2つのコマンドは、GLSLソースファイルを読み込んで、SPIR-Vバイトコードを出力せよ、ということを-o
(output)フラグを使ってコンパイラに伝えます。
もしシェーダに文法エラーが含まれていたら、あなたの期待通り、コンパイラは行番号と問題点を教えてくれます。試しにセミコロンを削除してコンパイルスクリプトを再度実行してみてください。また、どんな種類のフラグがサポートされているか見るために、何も引数を付けずにコンパイラを実行してみてください。例えば、バイトコードを人間が読めるフォーマットで出力することもでき、あなたのシェーダが実際に何をやっているのか、どのような最適化がこのステージで適用されているのかを見ることができます。
コマンドラインでシェーダをコンパイルするのは、最も単純な方法のうちの一つで、私たちもこのチュートリアルでそれを使いますが、あなた自身のコードから直接シェーダをコンパイルすることもできます。Vulkan SDKは、あなたのプログラム中からGLSLコードをSPIR-Vにコンパイルするためのライブラリである、libshaderc
を含んでいます。
Loading a shader
これでSPIR-Vシェーダを生成する方法を手に入れましたので、それらをプログラムにロードして、ある時点でグラフィックスパイプラインに接続する時が来ました。最初に、ファイルからバイナリデータをロードするための簡単なヘルパー関数を作成します。
#include <fstream>
...
static std::vector<char> readFile(const std::string& filename) {
std::ifstream file(filename, std::ios::ate | std::ios::binary);
if (!file.is_open()) {
throw std::runtime_error("failed to open file!");
}
}
readFile
関数は指定されたファイルから全てのバイトを読み込み、それをstd::vector
で管理されるバイト配列で返します。はじめに、2つのフラグを指定してファイルを開きます。
-
ate
:ファイルの末尾から読み始める -
binary
:ファイルをバイナリファイルとして読み込む(テキスト変換を避ける)
ファイルの末尾から読み始めることの利点は、読み込み位置を使ってファイルのサイズを決定し、バッファを確保できることです。
size_t fileSize = (size_t) file.tellg();
std::vector<char> buffer(fileSize);
その後、ファイルの先頭にシークして全てのバイトを一度に読み込むことができます。
file.seekg(0);
file.read(buffer.data(), fileSize);
そして最後に、ファイルを閉じてバイト配列を返します。
file.close();
return buffer;
createGraphicsPipeline
からこの関数を呼び出して、2つのシェーダのバイトコードをロードします。
void createGraphicsPipeline() {
auto vertShaderCode = readFile("shaders/vert.spv");
auto fragShaderCode = readFile("shaders/frag.spv");
}
バッファのサイズを出力して、実際のファイルのバイトサイズと一致しているかチェックし、シェーダが正しくロードされたことを確認してください。バイナリコードなのでNULL終端されている必要がなく、後でそのサイズについて明示することになります。
Creating shader modules
コードをパイプラインに渡す前に、それをVkShaderModule
オブジェクトにラップする必要があります。これを行うためのヘルパー関数、createShaderModule
を作成しましょう。
VkShaderModule createShaderModule(const std::vector<char>& code) {
}
この関数は引数にバイトコードのバッファを取り、そこからVkShaderModule
を作成します。
シェーダモジュールの作成はシンプルで、バイトコードのバッファへのポインタとその長さを指定するだけです。この情報はVkShaderModuleCreateInfo
構造体の中で指定されます。一つの落とし穴は、バイトコードのサイズはバイト単位で指定されるのに、バイトコードのポインタはchar
ポインタではなくuint32_t
ポインタであることです。そのため、下に示すようにreinterpret_cast
でポインタをキャストする必要があります。このようなキャストを行う時、データがuint32_t
のアライメント要求を満たしていることを確認する必要があります。ラッキーなことに、データはstd::vector
に格納されており、既にデフォルトアロケータが最悪のケースのアライメント要求を満たすことを保証してくれています。
VkShaderModuleCreateInfo createInfo{};
createInfo.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO;
createInfo.codeSize = code.size();
createInfo.pCode = reinterpret_cast<const uint32_t*>(code.data());
VkShaderModule
はvkCreateShaderModule
を呼び出すことで作成することができます。
VkShaderModule shaderModule;
if (vkCreateShaderModule(device, &createInfo, nullptr, &shaderModule) != VK_SUCCESS) {
throw std::runtime_error("failed to create shader module!");
}
引数はこれまでのオブジェクト作成関数と同じで、論理デバイス、作成情報構造体へのポインタ、カスタムアロケータへのポインタ(オプション)、そして出力変数のハンドルです。コードのバッファはシェーダモジュール作成後、直ちに解放することができます。作成したシェーダモジュールを返すのを忘れないでください。
return shaderModule;
シェーダモジュールは、前節でファイルから読み込んだシェーダバイトコードと、その中で定義されている関数の薄いラッパーに過ぎません。GPUによるSPIR-Vバイトコードからマシンコードへのコンパイルとリンクは、グラフィックスパイプラインが作成されるまで行われません。これは、パイプラインの作成が完了したらすぐにシェーダモジュールを破棄しても良いということを意味します。そのため、これらをクラスメンバではなく、createGraphicsPipeline
関数内のローカル変数にしています。
void createGraphicsPipeline() {
auto vertShaderCode = readFile("shaders/vert.spv");
auto fragShaderCode = readFile("shaders/frag.spv");
VkShaderModule vertShaderModule = createShaderModule(vertShaderCode);
VkShaderModule fragShaderModule = createShaderModule(fragShaderCode);
vkDestroyShaderModule
呼び出しを2つ追加して、関数の最後にクリーンアップが行われるようにします。この章の残りのコードは全て、これらの行の前に挿入されます。
...
vkDestroyShaderModule(device, fragShaderModule, nullptr);
vkDestroyShaderModule(device, vertShaderModule, nullptr);
}
Shader stage creation
実際にシェーダを使うにはパイプライン作成手順の一部として、VkPipelineShaderStageCreateInfo
構造体を通して、シェーダを特定のパイプラインステージに割り当てる必要があります。
再びcreateGraphicsPipeline
関数の中で、頂点シェーダ用に構造体を埋めるところから始めます。
VkPipelineShaderStageCreateInfo vertShaderStageInfo{};
vertShaderStageInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
vertShaderStageInfo.stage = VK_SHADER_STAGE_VERTEX_BIT;
まず最初のステップは、必須のsType
メンバに加えて、どのパイプラインステージでシェーダが使われることになるのかをVulkanに伝えることです。前の章で説明した、プログラマブルステージそれぞれに対応したenum値があります。
vertShaderStageInfo.module = vertShaderModule;
vertShaderStageInfo.pName = "main";
次の2つのメンバーは、コードを含んでいるシェーダモジュールと、エントリーポイントとして知られる、呼び出す関数を指定します。これが意味するのは、複数のフラグメントシェーダを一つのシェーダモジュールに結合し、振る舞いを変化させるために違うエントリーポイントを使うことが可能だということです。しかし今回の場合は、標準のmain
のままにします。
もう一つ、ここでは使わないですが議論する価値のある(オプショナルな)メンバ、pSpecializationInfo
があります。それはシェーダ定数の値を指定することができるようになります。あなたは一つのシェーダモジュールで、そこで使われている定数にパイプライン作成時、違う値を指定することによってそれの振る舞いを設定できます。これはレンダリング時に変数を使って設定するよりも効率的です。なぜなら、それらの値に依存しているif
文を取り除くような最適化を、コンパイラができるからです。もし、あなたがこのような定数を持っていないならば、我々の構造体初期化が自動的におこなっているように、このメンバにnullptr
を設定することができます。
フラグメントシェーダ用に構造体を変更するのは簡単です。
VkPipelineShaderStageCreateInfo fragShaderStageInfo{};
fragShaderStageInfo.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO;
fragShaderStageInfo.stage = VK_SHADER_STAGE_FRAGMENT_BIT;
fragShaderStageInfo.module = fragShaderModule;
fragShaderStageInfo.pName = "main";
最後にこれら2つの構造体を含む配列を定義し、後で実際のパイプライン作成時に参照します。
VkPipelineShaderStageCreateInfo shaderStages[] = {vertShaderStageInfo, fragShaderStageInfo};
パイプラインのプログラマブルステージについての説明は以上です。次の章では、固定機能ステージについて見ていきます。
C++ code / Vertex shader / Fragment shader
前の記事
次の記事