今回は一風変わったシェーダのお話です。
Vulkanでシェーダの中間表現としてSPIR-Vが採用されたことにより、実質あらゆる言語がシェーダ記述言語としての可能性を持つことになりました1。そんな中で、一足先にバックエンドをSPIR-V出力用に作り変えたNimコンパイラのパッチ+ツールである「nim2spirv」2を触ってみたいと思います。
シェーダといえば今でこそGLSL/HLSL/Cgなどといった「C言語亜種」のような言語で書くことがあたかも普通のようになっていますが、C言語みたいな旧来の命令形の言語ではどうしても記述が冗長になってしまいます。この冗長さは特にレイマーチングみたいに「9割がたreturnのみ関数で構成される」みたいなシェーダコードを書く際に不適で......要するに
/// https://www.iquilezles.org/www/articles/distfunctions/distfunctions.htm
float sdSphere( vec3 p, float s )
{
return length(p)-s;
}
これを
sdSphere :: Vec3 -> Float -> Float
sdSphere p s = length p - s
みたいにかけたらスッキリするし定義っぽく見えるしめっちゃ読みやすくてつまり最高なのでは?という感じのお話です3。
好きな言語でシェーダを書く歴史
nim2spirvを触る前に少しだけ。SPIR-V登場以前は好きな言語でシェーダを書くことが叶わなかったのか、というとそういうこともありません。
シェーダをGLSL/HLSL/Cg以外で書けるようにする場合、大まかに次の2つの手段が考えられます。
- コンパイラの中間表現をGLSLなどに変換する
- DSLでGLSLコードをコンパイル時or実行時に生成する
確実な情報ではありませんが、1番はUnityのShaderCompilerがCgから様々なものに出力する関係上、近いことをやっていそうな気がします。
2番のDSLを使う方法は、パッチを適用せずに本家のコンパイラで実装できる、IDEの支援などを受けられるなどの理由からかよく採用されている印象です。代表的なものとしてはD言語のdglslやHaskellのixshaderでしょうか。dglslはUDAをふんだんに活用していますし、ixshaderのほうは型にシェーダI/Oの情報を埋め込んでいて、GLSLとは大きくかけ離れているものの両者とも言語機能を上手にフル活用している感じがしますね。
使い始めるまで
では早速使っていきましょう。nim2spirvはパッチ適用済みのNimコンパイラに対してのラッパーツールとなりますので、まずは作者謹製のパッチ適用済みNimコンパイラをビルドします。手順としては本家README.mdに記載のとおりですが、いくつか罠があります。
まず、パッチ適用済みNimコンパイラのビルド中にtypetraitsがエラーを出すと思います。その場合は該当行を次のように書き換えてください。
- var a = clamp(unix, low(CTime), high(CTime)).CTime
+ var a = clamp(unix, low(CTime).int64, high(CTime).int64).CTime
Nimコンパイラがビルドできたら、あとはnim2spirv本体を パッチ適用版の nimbleでビルドするだけです。面倒なので一時的にパッチ適用済みNimコンパイラの実行ファイルがあるディレクトリにパスを通しちゃうのが良いと思います。
ここで、おそらくindentation error
が出てくると思います。その場合は、エラーの出た行のインデントを一つ深くしてください。そうするとエラーが解消され、nimble build
で実行ファイル(nim2spirv)が出来上がるはずです。
実際に使ってみる
さて、nim2spirvの書き心地検証として、以下の単純なテクスチャフェッチだけするGLSLコードのペアを書き直してみたいと思います。
...といきたかったのですが、sampler2D(OpTypeSampledImage/OpTypeImage)の出力が実装されていませんでした......
仕方がないので、受け取ったUVをそのまま出力するようにします。ここまで来るとREADMEに書いてあるのとあまり変わらないですね......
#version 450
layout(location = 0) in vec3 pos;
layout(location = 1) in vec2 uv;
out gl_PerVertex { out vec4 gl_Position; };
layout(location = 0) out vec2 uv_v;
layout(set = 0, binding = 0) uniform Camera
{
mat4 vp, obj;
};
void main()
{
gl_Position = transpose(vp) * transpose(obj) * vec4(pos, 1.0);
uv_v = uv;
}
#version 450
layout(location = 0) in vec2 uv_v;
layout(location = 0) out vec4 sv_target0;
void main()
{
sv_target0 = vec4(uv_v, 0.0, 1.0);
}
本家READMEを参考にして書き下すと、次のようになります(Nim初めて書いたので、Nim的に妙なところとかは大目に見てください......)。
import shaders
type
Camera = object
vp {.rowMajor.}: Matrix4x4
obj {.rowMajor.}: Matrix4x4
var
camera {.uniform, descriptorSet: 0, binding: 0.}: Camera
pos {.input, location: 0.}: Vector3
uv {.input, location: 1.}: Vector2
raster_position {.output, builtIn: Position.}: Vector4
uv_v_out {.output, location: 0.}: Vector2
uv_v_in {.input, location: 0.}: Vector2
sv_target {.output, location: 0.}: Vector4
proc project(p: Vector3): Vector4 = camera.vp * camera.obj * construct[Vector4](pos, 1.0'f32)
proc vsMain() {.stage: Vertex.} =
raster_position = project(pos)
uv_v_out = uv
proc fsMain() {.stage: Fragment.} =
sv_target = construct[Vector4](uv_v_in, 0.0'f32, 1.0'f32)
本家READMEのと違い、ここではUniformとしてMatrix4x4が2つあるものを受け取ってみています(これが後に罠となる)。
また、試しに座標変換を関数に切り出してみました。returnとか{}とかかかなくてよくて1行でスッと書けるのでとてもよい見た目です。
このコードは
$ nim2spirv spirv prim.nim
でコンパイルすることができ、prim.spv
が出力されます。
prim.nimでshadersをimportしているため、nim2spirvに付属のshaders.nimとprim.nimが同じフォルダにいる必要があります。nim2spirvのフォルダにprim.nimを置くのが一番簡単です。
メンバのオフセット出力がまだ正しくない話
さて、これでNimコードからSPIR-Vモジュールが得られたわけですが、残念ながらこのSPIR-Vモジュールは使えないモジュールです。
nim2spirvは構造体のメンバオフセットの計算がほぼ未実装の状態のようで、上記コードのCameraのobjはオフセット1に配置されるようにDecorate命令(OpMemberDecorate)が出力されます。
READMEに思いっきり
Note: This is an early proof-of-concept and work-in-progress!
と書いてあるので未実装なのはそらそうやろ、という感じですが、今回は半ば無理やり使えるものを出すためにちょっと雑すぎるパッチを当てています。
- m.decorationWords.addInstruction(SpvOpMemberDecorate, result, i.uint32, SpvDecorationOffset.uint32, member.sym.position.uint32)
+ m.decorationWords.addInstruction(SpvOpMemberDecorate, result, i.uint32, SpvDecorationOffset.uint32, member.sym.position.uint32 * 64)
Offset Decorateを出力する際に、メンバーポジションに64を掛けています。mat4は64bytes(sizeof(float)*4*4
)なので、構造体のメンバが すべてMatrix4x4の場合に限り (つまり今回の場合だけ)正しい出力が得られます。
本来はちゃんと次のオフセットを型のサイズから計算したりアラインメント適用したりするべきなんですが、そこまでの熱量はないので今回はお茶を濁しています。
おわりに
nim2spirvはまだほんとに「やってみた」段階なのでほんとに「こんなことができるんだ?」くらいの感じだと思いますが、それでもGLSLじゃない言語から使えるシェーダができてしまう、というのはかなり夢のあるお話だと思います。
実際、Nimという、C言語とは全く形が異なる言語でシェーダコードを書く、というのは割と新鮮な感じでした。
今回の書いてみたやつは https://github.com/Pctg-x8/peridot/tree/ft-load-raw-spv/examples/image-plane においてあります。
- assets/shaders/prim.nimが今回書いたNimシェーダコード(この言い方もちょっと新鮮)
- src/lib.rsがドライブするRustコード
になっています。後者は使っているフレームワークのドキュメント類全然整備できてないのでとても読めるものではないと思いますが、167行目から始まるコードがおおよそSPIR-Vモジュールを読んで、PipelineStateの頂点処理まわりの情報を作っている箇所になっています。