3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Vulkan Tutorial (Drawing a triangle/Graphics pipeline basics/Shader modules) 日本語訳

Posted at

この記事は Shader modules - Vulkan Tutorial の日本語訳です。

Shader modules

それまでのAPIと違い、Vulkanでのシェーダコードは、GLSLHLSLのような人間が読める文法とは対象的な、バイトコードフォーマットで指定する必要があります。このバイトコードフォーマットは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になります。ベクトルのコンストラクタは、ベクトルとスカラー値の組み合わせを取ることもできます。例えば、vec3vec3(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は、現在の頂点のインデックスを格納しています。これは通常、頂点バッファ内のインデックスですが、今回のケースでは、頂点データがハードコーディングされた配列のインデックスです。それぞれの頂点の位置はシェーダ内の配列定数からアクセスされ、ダミーのzw要素と組み合わさってクリップ座標での位置を生成します。組み込み変数の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());

VkShaderModulevkCreateShaderModuleを呼び出すことで作成することができます。

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

前の記事
次の記事

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?