GLSL に変換できるシェーダ言語を作った

欲しかったので作ってしまいました。HGSL 開発リポジトリはこちら。Haxe の環境を VSCode に導入すれば誰でも使えます。

以下開発経緯や言語の特長、技術的解説、苦労した話などが続きます。

なぜ作ろうと思ったか

AltGLSL が欲しかったからです。Works にあるように以前から WebGL を用いた作品で GLSL を書いてきましたが、

  • GPGPU のような複雑なロジックを書こうとするとなかなかしんどい
  • 実行したときにブラウザ上でエラーが判明するのがしんどい
  • int から float への暗黙の型変換すら存在しないのがしんどい [1]
  • typo がその場で判明しないのがしんどい

など様々な不満が毎日少しずつ蓄積していきました。文字列としてプログラムに埋め込んでいるのが悪いと言えば悪いのですが、メインのプログラムと uniform 変数の名前を揃える必要性もあり、String を使ったメタプログラミング的な手法に頼らざるを得ませんでした。

そうこうしているうちに WebGL 1.0 の最後の足枷だった iOS が WebGL 2.0 に対応した iOS 15 をリリースし、様々な機種のスマホで全面的に WebGL 2.0 を使える時代がやってきました。これを機に利用していたオレオレライブラリも中身を一新、WebGL 2.0 標準の便利機能を取り入れて使いやすくすることにしました。

使えるシェーダ言語も GLSL ES 2.0 から GLSL ES 3.0 になります。MRT や texelFetch などの便利機能が解放され、ますます GPGPU を使った作品が作りやすくなります

ここでアイデアが浮かびます:

「このまま文字列を埋め込むコーディングを続けるくらいなら使いやすいシェーダ言語を作った方がよくないか?」

こうして自分用のシェーダ言語を開発することになります[2]

欲しかった機能

当初の目的は次のようなものでした[3]

  • コンパイル時に GLSL を生成してメインのプログラムから文字列として参照したい
  • コンパイル時にエラーが判明してほしい
  • 暗黙の型変換をしてほしい

筆者は HTML5/JS を用いた作品制作に Haxe を利用しており、幸いにも Haxe は強力なマクロをサポートしています。具体的には、コンパイル時に AST(抽象構文木)をいじくり倒して生成されるクラスのフィールドを好きなように変更できるというものです。こいつを利用すれば

  • 字句解析器・構文解析器を作る必要がない
  • 生成したフィールドはメインのプログラムからシームレスに参照できる
  • IDE の支援を受けられる可能性がある

といいことずくめです。これを利用しない手はありません

できた

なんやかんやあって無事完成しました。思い立ってから公開まで約一ヶ月というスピード工事でしたがどうにかなりました。

言語の特長

  • コンパイル時に GLSL ES 3.0 に準拠したコードに変換可能
  • 可能な限りエラーをコンパイル時に検出
  • 最新版の GLSL の仕様に基づく暗黙の型変換 (intuintfloat) が可能
  • GLSL のビルトイン関数・変数を参照可能
  • IDE による補完、定義の参照、型の表示などの支援を受けられる
  • JSON ライクな記法による無名構造体のサポート
  • 複数シェーダに共通するロジックや定数をモジュールとして外部化し、再利用可能
  • シェーダの継承による変種の作成
  • 変数定義時の型推論 (C++ の auto)、構造体・配列リテラル内での型推論
  • 高階関数のサポート
  • クロージャのサポート
  • アロー関数のサポート
  • 静的に型付けされた uniform・attribute 変数一覧の取得が可能

苦労した甲斐あってかなり理想に近い言語が誕生しました。太字が個人的に特に嬉しいポイントです。詳細な仕様や導入方法は開発リポジトリの README を参照してください。

基本的に自分用に開発したのですが、普段 Haxe をメインの開発に使っていない人でも利用できるように、他のライブラリから独立しており、純粋に GLSL のソースコードを出力するだけの目的でも利用することもできるようになっています。例えばコンパイル時にソースコードを *.vert*.frag などのファイルに出力、後に JavaScript からファイルを読み込む、といった使い方も可能です。

メインのプログラム側も Haxe で開発している場合、追加の特典として uniform・attribute 変数のシームレスな解決が可能です。つまり、シェーダ側での名前変更が即座にメインプログラム側にも反映されます。この辺りも嬉しいポイントです。

さらに高階関数やクロージャといった高級な機能が利用可能なため、「レイ交差時のコールバック関数を受け取って交差判定を行う関数」のような、従来では切り離しが不可能であったようなロジックの外部化も可能となり、シェーダの再利用性に大きく貢献します。

自分で使ってみて

リポジトリのサンプルにもありますが、簡単な Phong のシェーディングモデルによるライティングを行うシェーダを書いてみました。

動いています。このくらい簡単なシェーダだと直に GLSL を書いても辛くはないですが、uniform 変数に構造体が使われているときなどでもきちんと補完が効いてくれるのは嬉しいです。

また、シェーダの変種を作りたいときに、基本のシェーダを継承して必要な変数の追加と関数のオーバーロードを行うだけで済むのも嬉しい点です。もちろん引き継いだ変数や関数は IDE 上で補完が効きます。super キーワードを使った親クラスの実装も参照可能です。

// extend the class to make a variant
class PhongShaderTextured extends PhongShader {
    // add another uniform
    @uniform var textureColor:Sampler2D;

    // override the color function
    function computeBaseColor():Vec4 {
        return texture(textureColor, vTexCoord) * vColor;
    }
}

GPGPU してみる

もう少し複雑な例として、Chill のシェーダを全て HGSL に移植して GPGPU に耐えられるか試してみました。

うまく動いています。

GPGPU ではテクスチャの参照周りのロジックが複雑になるので、テクスチャを3次元のデータとして扱うためのロジックをモジュール化して切り出すなどしてリファクタリングを行いました。

プログラムの見通しがだいぶ良くなりました。

今後

今後のシェーダ開発は全面的に GLSL から HGSL に切り替えます。使っているうちに不便な点などがまだ出てくると思うので、機能追加やバグ修正などを順次行っていきます。ほとんどいないとは思いますが、現状で Haxe を使って WebGL 開発をしている人にとっては間違いなく最高の選択肢になると思っています。

また、将来的には GLSL 以外の言語に出力可能にする方針も検討しており、もしかしたらですが Unity の ShaderLab に出力できるようになるかもしれません(今後の VRC への没頭具合と ShaderLab の開発環境の快適さによります)。まあでもあまり期待はしないでください。

技術的な話

シェーダ言語開発

実はシェーダ言語を作るのは2回目になります。かつて Adobe Flash Player の Stage3D という機能で GPU が初めて利用できるようになったとき、開発者は AGAL という言語でシェーダを書く必要がありました。

この AGAL ですが、正式名称は Adobe Graphics Assembly Language であり、その正体はアセンブリ言語です。さすがにアセンブリを手書きする必要があるとなると簡単なライティングを行うだけでも相当にしんどく、GPGPU などできたものではありませんでした。

その時作ったのが OGSL で、比較的まともなシェーダ言語から構文解析・意味解析・レジスタ割り付け・最適化などを行い最終的にアセンブリを出力してくれます。今見るとソースコードが本当に酷いですが、これでもどうにか動いてくれていました。大学の合格通知から入学式までの1ヶ月くらいで作った記憶があります。

今回は実装に Haxe のマクロを使用したため、字句解析器や構文解析器の作成といった虚無作業が発生せず、中身の実装に尽力できました。最終的な出力もバイナリではなく GLSL のソースコードでいいので、レジスタ割り付け等の処理も不要になります。

ビルドマクロ

前述のように Haxe には生成されるコードの構文木をいじり倒せる機能があり、build macro と呼ばれています。日本語の説明は shohei909 さんの Haxe のマクロ本が非常に詳しいです。長年お世話になっています。

型付け前の AST を受け取る → 自由に AST を変更する → 型付けされて出力される

という流れなので、理論上何でもできます。

しかし、「IDE と連携させる」という点が非常に難しく、かなり回りくどい実装をしている部分もあります。下手なことをするとすぐに IDE による補完が効かなくなります

Haxe のビルドマクロは一部の人々からは黒魔術と呼ばれており、その強大すぎる力によって身を滅ぼしかねないため使用は最小限にすべきとされています。加えて、ビルドマクロ自体の機能が非常に複雑であるためか、込み入ったことをしようとすると Stack Overflow したりメモリリークのような挙動をしたりと、かなりの確率で Haxe のコンパイラのバグに遭遇します

今回の開発中だけでもマクロ関連のバグを3つ報告しており、再現方法が確立できていないなどの理由で報告していないものを含めるとかなりの数になります。かなり苦しめられましたが、そのうち直ってくれると嬉しいですね。HGSL をライブラリとして使う上で致命的なものは今のところないと思います。

IDE との連携

Haxe はコンパイラによるソースコードの情報提供をサポートしており、これによってマクロを適用した後の情報に基づく補完がエディタ上で可能になります。これは非常に便利なのですが、マクロの適用をうまくやらないと補完が一切効かなくなるということでもあります。

ビルドマクロで渡される AST には pos というフィールドが付いており、これがソースコード上での対応する位置情報を保持します。コンパイラは、ソースコードのある位置で補完を要求された場合、マクロ適用後の AST の pos データを参考に構文木上の位置を特定し、適切な候補を返します。

つまり、いくら変換後に不要になったからといって変換前のコードが入った AST のノードをそのまま破棄してしまうと、ソースコード上の位置データも吹き飛んでしまい補完が効かなくなります。そのためこっそり見えない関数を作り、その中に元の AST を保持してあります。

元の AST を保持しておくことでその後コンパイラによる型付けが行われ、エラーが発生した場合に通常のコンパイルエラーとして知らせてくれます。これは一長一短です

  • 自前のトランスパイラで捉えきれない型エラーについて Haxe 側でエラーを出してくれる
  • 自前のトランスパイラ上で型が解決できていても Haxe 側で解決できないとエラーが出てしまう

後者は非常に困ります。トランスパイラ側による言語の変換が完了しても、Haxe 側でエラーを吐かれると当然ビルドは止まってしまうためです。これに対応するため、Haxe 側の構文で対応しきれない部分についてはエラーが出ないように AST を修正した後で見えない関数に隠すようにしてあります(switch 文の定数を使った case など)。

型解決

これは一般的なコンパイラによる流れと大して変わらないと思います。構文解析までは済んでいるので、トップダウンに構文木を巡回していき型を付けていきます。関数中のローカル変数は出会う度に環境中に定義し、ブロックの先頭でスコープの push、ブロックの末尾でスコープの pop を行うなどしています。

苦労したのが演算子のオーバーロード機能です。

GLSL は皆さん後存じのように演算子オーバーロードが非常に積極的に行われており、複数ある候補の中から適切な演算の型を選択する必要があります。Haxe にも演算子のオーバーロード機能があるため、GLSL に合わせた定義を行えば OK です。

と思っていたのですが、そうもいきませんでした。両者の機能にかなりの差があるためです。

最新の GLSL では暗黙の型変換が有効であり、これは演算子のオーバーロードと合わせて行われることがあります。

例えば、vec2float を足すことで要素ごとにスカラー型された vec2 が得られる演算があります。

vec2 + float = vec2

実は、vec2intivec2 を足しても vec2 が得られます。これは、int から float への暗黙の型変換が行われるため[4]です。

vec2 + int   = vec2
vec2 + ivec2 = vec2

さて、適用される暗黙の型変換は最小回数のものを選択する必要があります。例えば int + uint を実行した場合、左辺が int から uint へ変換され、結果は uint になります。いくら uint から float への変換がさらに可能であったとしても、ここで結果を float にしてはなりません

しかし、可能な選択肢を Haxe の演算子オーバーロード機能でそのまま実装すると問題が生じます。Haxe の演算子オーバーロード解決が賢くなく、なんと見つけた候補を片っ端から適用し、成功した時点でその候補に解決してしまうためです。この適用には暗黙の型変換が含まれるため、必要以上の型変換が行われておかしな結果が返ってくる可能性があります。

トポロジカルソートを行う

この挙動は外部からは変更できないため、どうにかして問題を回避する必要があります。

実は、この問題は「見つけた順に片っ端から試しても問題ない順序でオーバーロードを定義する」ことで解決できます。

各演算の型には暗黙の型変換可能性に準ずる半順序を付けることができます。例えば、int, uint, float はそれぞれ順に暗黙の型変換が可能であるため、intuint, uintfloat のように順序を付けます(反射律より intint、推移律より intfloat も成り立ちます)。そして、int + int のような演算について、両辺がともに変換可能であるときに順序を付けます。

  • int + intint + uint
  • int + uintint + float
  • int + intuint + uint
  • uint + uintuint + float

こうして順序を付けたとき、暗黙の型変換により適用可能な候補のうち、順序関係において最小のものが選ばれるべき候補になります。つまり、最初から候補がこの順序関係について小さい順に並んでいれば先頭から処理しても何も問題ないことになります。

半順序は比較不能な元(例えば float + intint + float は比較不能)な元があるため単純に一列に並べることはできませんが、トポロジカルソートを利用して半順序を矛盾しない全順序に拡張し、一列に並べることができます。こうして Haxe 側の演算子オーバーロードと GLSL の演算子オーバーロードの型を揃えることができました。

その他

他にも苦労した点は多々ありますが、全て上げるとキリがないのでこの辺にしておきます。

リポジトリの Readme に詳細な使用方法があるので、興味がある方はぜひ使ってみてください。何かあれば作者の Twitter までどうぞ。


  1. 最新の GLSL では暗黙の型変換をサポートしているのですが、WebGL で使えるバージョンの GLSL ES 3.0 (or 2.0) では暗黙の型変換を一切サポートしていません ↩︎
  2. Haxe から GLSL に変換できるライブラリとして Heaps の HXSL があったのですが、ゲームエンジンに組み込まれている上に欲しい機能が全部そろっているわけでもなかったので自作することにしました ↩︎
  3. 後に(予想通り)この要求はどんどん拡大していくことになります ↩︎
  4. ベクトル型についても、ivec2vec2 のように要素の型が暗黙に変換可能である場合は暗黙の型変換が行われます ↩︎

このエントリーをはてなブックマークに追加