こんにちは。普段はRuby/Railsを書いていて、最近はVRM(3Dアバターのフォーマット)まわりを触っている初心者エンジニアです🔰。
先日 glslkit という gem を RubyGems に公開しました。というか、気づいたら3つ公開してました。
-
glslkit— GLSLの前処理・解析コア(依存ゼロ) -
glslkit-rails— Propshaft統合とライブリロード -
glslkit-webgl— ruby.wasm上のWebGL2ランタイム
この記事は「なにを作ったか」より「なんでこうなったか」の記録です。設計を間違えた場所も含めて書きます。そっちのほうが役に立つと思うので😄。
発端:「WebGLのgemって作れるのかな?」
事の始まりは、ほんとにこの一言でした。
でもここでいきなり壁にぶつかります。WebGLはブラウザのAPIなので、そもそもRubyの居場所がないんですよね。
📘 WebGL / WebGL2
ブラウザからGPUを直接叩くためのAPI。<canvas>に対して「この頂点をこう並べて、この色で塗って」という命令を投げます。WebGL2はその第2世代で、いまのモダンブラウザはほぼ対応済み。
じゃあRubyをどこに噛ませるの? を整理したら、方向性は大きく4つありました。
- ruby.wasm + WebGLバインディング — ブラウザ上でRubyを動かして直接WebGLを叩く
-
シェーダのアセット管理 —
.glslファイルをRailsのアセットパイプラインに乗せる - glTF/VRMパーサ — サーバサイドで3Dモデルを読む
- マウンタブルなビューワEngine — three.jsを同梱したRails Engine
3と4は既存の作業と地続きで手堅い。でも私が惹かれたのは1と2でした。1は前例がほとんどなくて面白そう、2は実務に効きそう。
で、欲張って 「この2つ、まとめられない?」 と考えたところから設計が始まります。
気づき:この2つを繋ぐと「ビルド時リフレクション」になる
まとめる、といっても1つのgemにするのは無理です。片方はビルド時にサーバで動いて、片方はブラウザのランタイムで動く。実行環境が完全に別なので、依存を同居させたら破綻します。
なので 「共通コア + 2つのアダプタ」 という形にしました。
glslkit/
├── core/ # gem: glslkit 依存ゼロ。純粋な文字列処理
├── rails/ # gem: glslkit-rails Propshaft統合
└── webgl/ # gem: glslkit-webgl ruby.wasm上のランタイム
ここで「この構成にする意味あるの?」という話になります。ただ分割しただけならバラバラのgemでいいわけで。
繋ぐ意味が生まれるのは、ビルド時リフレクションでした。
📘 リフレクション(Reflection)
プログラムが「自分自身の構造」を調べること。ここではGLSLソースを解析して「このシェーダにはu_model_viewという名前のmat4型のuniformがある」といった情報を取り出すことを指します。
普通のWebGL開発だと、実行時にこういうループを回してuniformの場所を引きます。
// 起動のたびにGPUに問い合わせる、よくあるやつ
const n = gl.getProgramParameter(prog, gl.ACTIVE_UNIFORMS);
for (let i = 0; i < n; i++) {
const info = gl.getActiveUniform(prog, i);
locations[info.name] = gl.getUniformLocation(prog, info.name);
}
📘 uniform
シェーダに外から渡す定数のこと。カメラ行列とか、光の色とか。フレームごとに書き換えます。GPU側で「何番地にあるか」(location)を引かないと値を書き込めません。
でも、この情報ってビルド時にソースを読めば全部わかるはずなんですよ。 だったらRails側でプリコンパイルするときにJSONに吐いておいて、ruby.wasm側はそれを読むだけにすればいい。起動時のループが丸ごと消えます。
しかも型がわかっているので、こう書けるようになります。
program = Glsl::Program.load("pbr") # 埋め込み済みリフレクションから復元
program.u_model_view = mat4 # 内部で uniformMatrix4fv にディスパッチ
program.u_albedo = texture # 型不一致はビルド時に検出済み
生のWebGLだと gl.uniformMatrix4fv(loc, false, arr) を手で書き分けるところが、代入で済む。アセット側とランタイム側が繋がった瞬間に価値が跳ねた感じがして、ここで「これは作る価値がある」と確信しました。
v0.1で決めた「契約面」
最初のリリースで一番こだわったのは、リフレクションJSONのスキーマを確定させることでした。
ここが2つのアダプタの契約面(インターフェース)になるので、後から変えると両側が壊れます。逆にここさえ固まっていれば、wasm側の実装は後回しにできる。
なので v0.1 では wasm側を実装しないと決めて、コア + Railsアダプタだけ出しました。JSON Schema(Draft 2020-12)でスキーマを書いて、生成したマニフェストが必ずそれを満たすことをテストで担保しています。
📘 JSON Schema
「このJSONはこういう形をしていなければならない」を宣言的に書く仕様。型やネスト構造をバリデーションできます。契約面をコードの外に置けるのが強み。
設計上の制約を2つ、最初に決めた
1つめ:コアはactivesupportに依存させない。
これは絶対に譲れませんでした。ruby.wasmはRubyインタプリタごとブラウザに配信するので、依存が増えるとwasmバイナリのサイズに直撃します。 String#camelize くらいなら自前で書けばいい。数行です。
2つめ:#include のresolverを抽象化する。
GLSLには #include が存在しません。 標準にないんです。なのでみんな自前で実装するんですが、「ファイルをどこから読むか」が環境によって違う。Rails側はPropshaftのload path、wasm側は埋め込み済みハッシュを引くだけ。この差をresolverオブジェクトの注入で吸収しました。 コアはファイルシステムを一切触りません。
📘 Propshaft
Rails 7以降の新しいアセットパイプライン。Sprocketsの後継で、「もう変換はesbuildとかに任せるから、こっちは配信とダイジェスト付与に専念します」という潔い設計になっています。
測ってから設計する:スパイクの話
wasm側に着手するとき、いきなり仕様書を書くのはやめました。
だって、そもそもruby.wasm上のWebGLが実用速度で動くのか誰も知らないんですよ。前例がほぼないので。ここで想像で設計すると、測定値に殴られて全部書き直しになります。
なので先に スパイク(性能検証用の使い捨てコード) を書いて、ブラウザで実測しました。
一番の懸念は JSブリッジ越えのコストでした。
📘 JSブリッジ
ruby.wasm上のRubyコードは、ブラウザAPIを直接は叩けません。JS.global経由でJavaScript側に呼び出しを転送します。この往復に毎回オーバーヘッドが乗るので、描画ループみたいな毎フレーム走る処理では致命傷になりえます。
特に bufferData(頂点データをGPUに送る処理)で、Rubyの Array<Float> をどうやってJSの Float32Array にするか。ここが桁で遅かったら設計そのものを変える必要がありました。頂点数に比例する仕事をRuby側に置けなくなるので。
4通りの方法をベンチマークして、結果はこうでした。
| 項目 | 実測値 |
|---|---|
| フレームレート | 60fps(最大フレーム時間 17.7ms) |
| GC発生フレーム | 通常フレームと区別がつかない |
| 起動時間 | 117〜178ms(キャッシュ温状態) |
| 初回転送量 | 約9MB |
通ってしまいました。 特にGCが目立たなかったのが意外で、正直ここが一番危ないと思っていたので拍子抜けしました。
もう一つ、「成立した」の定義を先に決めたのもよかったです。これがないと永遠に終わらないので。
.rbファイルだけを書いて、テクスチャ付きの立方体が60fpsで回る。
uniformのlocation解決はマニフェスト由来で、getActiveUniformを呼ばない。
そしてシェーダのコンパイルエラーが、Rubyの例外として元ファイル名と行番号付きで上がる。
最後の一項がこのプロジェクト固有の価値だと思っています。three.jsでもglslifyでも、シェーダのエラーはブラウザのコンソールに文字列で出るだけ。それがRubyの例外として構造化されて上がるなら、他にないものになります。
そのために作った SourceMap
「元ファイル名と行番号」を出すのは、思ったより厄介でした。
#include を解決した時点でファイルは1枚に平坦化されます。GPUから返ってくるエラーは平坦化後の行番号なので、そのまま出しても意味がない。
ERROR: 0:143: 'u_lightColor' : undeclared identifier
143行目って言われても、それは結合後の143行目であって、私が書いた lighting.glsl の何行目なのかは誰も知りません。
なので セグメント単位で位置情報を持つ SourceMap を実装しました。平坦化のときに「出力の何行目から何行目は、どのファイルの何行目由来か」を記録しておいて、SourceMap#resolve で逆引きする。
📘 ソースマップ
変換後のコードの位置を、変換前の位置に戻すための対応表。JavaScriptのminifyやTypeScriptのコンパイルでおなじみのアレと同じ発想です。
あわせて #pragma once にも対応しました。C系でおなじみの、同じファイルが2回includeされても1回しか展開しないやつです。共通のライティング関数とかを複数のシェーダから読むと、これがないと関数の二重定義で即死します。
さらに Validator も入れました。診断ルールは11個(エラー7つ E001〜E007、警告4つ W001〜W004)。ここは正直、実際に自分がハマったものからしか良いルールは決まらないと思っていて、リストで先に11個決めたうちの何個が本当に役に立つかは、まだわかっていません。使いながら削るつもりです。
設計が間違っていた場所(ここが本題かも)
実装は私の初期設計の間違いが実装前に5つ見つかりました。
| 思い込み | 実際 |
|---|---|
Reflection は位置情報を持っている |
持ってなかった。SourceMapが必要だった |
Validator はマージ前のデータを見られる |
見られなかった。パイプラインの順序が逆 |
Program#reload に責務を置けばいい |
責務の切り方が間違い。Context#reload_program が正解 |
| ダイジェスト計算は常に成功する | プリプロセッサがエラーだと計算できず落ちる |
ruby.wasmで .await が使える |
vm.eval の実行経路では使えなかった |
最後のやつが一番厄介でした。ライブリロード(シェーダを保存したら画面が即座に切り替わるやつ)を実装するのに非同期処理が必須だったのですが、.await が使えない。
結果、.then + Proc を渡すコールバック形式に書き換えて、500msのポーリングで実装しました。
# .await が使えないので、Procをコールバックとして渡す
JS.global.fetch(url).then(proc { |res|
res.text.then(proc { |body| reload_program(body.to_s) })
})
うーん、書き味は正直よくないです。でも動きます。**そして「動かない前提で設計していたら、ライブリロード機能そのものを諦めていた」**と思うと、調査してから実装する運用の価値がよくわかりました。
責務の切り方の話も少し補足すると、最初は Program#reload(シェーダプログラム自身が自分を再読込する)にしようとしていました。でも実際は、プログラムを差し替えるには描画コンテキストの状態を触る必要があるんですよね。自分で自分を差し替えられない。なので Context#reload_program に落ち着きました。オブジェクト指向の教科書に載ってそうな間違いを普通にやっていて、ちょっと恥ずかしいです。
Railsアダプタ側で地味にこだわったところ
CSP nonce対応
ビューヘルパーでシェーダをインラインに埋め込むとき、Content Security Policy の nonce をちゃんと通すようにしました。
📘 CSP nonce
インラインの<script>や<style>はXSSの温床なので、CSPを有効にすると原則ブロックされます。そこでサーバがリクエストごとにランダムな文字列(nonce)を発行して、それを持つタグだけ許可する仕組み。
「サンプルは動くけど本番のCSP設定で死ぬgem」って結構あるので、ここは最初から入れておきたかった部分です。
Safariの file:// 問題
動作確認していて気づいたのですが、Safariでは file:// からのシェーダ読み込みが失敗します。 fetchの制約です。Chromeだと動くので、サンプルをそのまま開いた人がハマるやつ。
レンダリング自体はSafariでも動くことを確認済みですが(ライブリロードは未検証)、これはドキュメントに全部書きました。動かない条件をREADMEに書いておくのは、機能を1つ足すのと同じくらい価値があると思っています。
「JSを1行も書かなくてよい」の正確な意味
これ、最初は「JSがゼロになる」と書こうとして、途中で言い直しました。
ローダとバッファ用の薄いシム(JS側の橋渡しコード)は同梱しています。なので正しくは 「JSが1行も存在しない」ではなく「JSを1行も書かなくてよい」。
この区別を曖昧にしたまま出すと、確実に突っ込まれます。 誇張して注目を集めるより、最初から正確に書いたほうが長期的には信用されるはず、と自分に言い聞かせました。
まとめ:設計判断の順番
振り返ると、うまくいった判断はだいたい**「決める順番」**の話でした。
- 契約面(リフレクションJSONスキーマ)を最初に固める → 片方を後回しにできる
- 測定値が出るまで仕様を書かない → 想像で設計して書き直すのを防げる
- 「成立した」の定義を先に決める → 無限に作り込むのを防げる
- 実装前に調査させる → 思い込みが実装前に5つ死んだ
- できないことを先に書く → 「JSゼロ」と言わない、Safariの制約を書く
技術的な難しさより、こういう順番の判断のほうが結果に効いた気がします。
環境
- Ruby >= 3.1
- テスト: minitest
- Lint: standardrb
- Rails側: railties >= 7.1 / propshaft >= 1.3.2
まだ v0.1.0 で、Validatorのルールも実戦で削る予定です。使ってみて「これ違うくない?」があったらIssueで教えてもらえると嬉しいです。
RubyでWebGL、思ったよりちゃんと動きます。よかったら触ってみてください🐚