17
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Nimでシェーダを書く

Last updated at Posted at 2019-12-14

今回は一風変わったシェーダのお話です。

Vulkanでシェーダの中間表現としてSPIR-Vが採用されたことにより、実質あらゆる言語がシェーダ記述言語としての可能性を持つことになりました1。そんな中で、一足先にバックエンドをSPIR-V出力用に作り変えたNimコンパイラのパッチ+ツールである「nim2spirv2を触ってみたいと思います。

シェーダといえば今でこそ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;
}

これを

架空のコード(シェーダonHaskell)
sdSphere :: Vec3 -> Float -> Float
sdSphere p s = length p - s

みたいにかけたらスッキリするし定義っぽく見えるしめっちゃ読みやすくてつまり最高なのでは?という感じのお話です3

好きな言語でシェーダを書く歴史

nim2spirvを触る前に少しだけ。SPIR-V登場以前は好きな言語でシェーダを書くことが叶わなかったのか、というとそういうこともありません。
シェーダをGLSL/HLSL/Cg以外で書けるようにする場合、大まかに次の2つの手段が考えられます。

  1. コンパイラの中間表現をGLSLなどに変換する
  2. 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に書いてあるのとあまり変わらないですね......

prim.vert
#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;
}
prim.frag
#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的に妙なところとかは大目に見てください......)。

prim.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!

と書いてあるので未実装なのはそらそうやろ、という感じですが、今回は半ば無理やり使えるものを出すためにちょっと雑すぎるパッチを当てています。

nim2spirv/src/spirvgen.nim(292行目)
-        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の頂点処理まわりの情報を作っている箇所になっています。

  1. DirectXサイドでもDXILというLLVMベースの中間表現があるので、Vulkanでしかできない、ということはないです

  2. このプロジェクトはまだProof-of-Conceptです

  3. この例は容易にPoint-Free Styleにできてもっと短くできるとか、pとs逆にしたほうが部分適用で恩恵があるとかいろいろあるんですが、そういったツッコミはなしでお願いします......

17
5
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
17
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?