Rubyのコードが輪の形に並び、実行するたびに次の形を生み出す。周期Quine「OUROBOROS」は、24世代で最初のソースへ戻る作品です。
公開作品はこちらです。
1本目では、自分のコードを再生成する仕組みを解説しました。2本目では、世代番号と数式から輪を作り、その中へコードを配置する処理を見てきました。
今回は、ブラウザで動く作品へつながる部分です。RubyをGLSLへ埋め込み、GPUでコードを描き、そのピクセルからRubyを復元して実行します。
一世代を進める経路
作品の世代交代は、次の順序で進みます。
現在のRubyを含むGLSL
↓ WebGL2でコードシートを描く
コードシートのピクセル
↓ 各文字に添えた8ビットの点列を読む
現在のRubyを復元
↓ ruby.wasmで通常実行
次の世代のRuby
↓ --shaderモードで実行
次の世代のGLSL
↓ glslkitで処理し、WebGL2で描く
次のコードシートと光の輪
最初のRubyとシェーダーは、起動時に読み込みます。その後、世代を進めるために実行するRubyは、コードシートのピクセルから復元したものです。
ソース文字列も比較用に保持しています。復元結果が現在のソースと一致することを確認してから、その復元結果をRuby処理系へ渡します。
Rubyに二つの出力モードを持たせる
生成されたRubyは、通常実行すると次の世代のRubyを出力します。
ruby public/ouroboros.rb > next.rb
もう一つの出力モードが --shader です。
ruby public/ouroboros.rb --shader > frame.frag
こちらは、その世代自身を描くためのGLSLを出力します。
| 実行方法 | 出力 |
|---|---|
| 通常実行 | 次の世代のRuby |
--shader |
現在の世代のRubyと描画処理を含むGLSL |
世代交代では、まず現在のRubyを通常実行して次のRubyを得ます。続いて、次のRubyを --shader で実行し、次の姿を描くシェーダーを作ります。
GLSLのテンプレートは、圧縮されたデータとしてRuby本体に埋め込まれています。--shader ではそれを展開し、ソースのバイト列や世代番号などを差し込みます。
ソースを4バイトずつGLSLへ渡す
シェーダーには、Rubyのソース全体をデータとして持たせます。
Ruby側では、ソースをバイト列へ変換し、4バイト単位で32ビットの整数に詰めます。たとえば、先頭が次の4文字だったとします。
n=0x
各文字のバイト値は、次の通りです。
n = 0 x
6e 3d 30 78
先頭のバイトを整数の下位側へ入れると、次の値になります。
0x78303d6eu
この値を、GLSLの uint 配列へ並べます。
const uint SRC[...]=uint[](...);
ソース末尾が4バイトに満たない場合は、ゼロを足して詰めます。元のソース長は LEN として別に保持するため、埋め合わせのゼロをソースとして扱うことはありません。
GLSL側では、次の関数で任意の一バイトを取り出します。
uint B(int i){
return i>=0&&i<LEN
? (SRC[i>>2]>>(8*(i&3)))&255u
: 0u;
}
i >> 2 で配列の何番目かを求め、i & 3 でその整数内の何バイト目かを求めます。必要な分だけ右へシフトし、255u とのANDで下位8ビットを取り出します。
この B(i) を使うと、コードの表示にも光の輪の文字にも、同じソースデータを使えます。
読むためのコードシートを描く
シェーダーは、u_mode に応じて描画を切り替えます。
| モード | 描くもの |
|---|---|
0 |
ソースのバイト値をRGBへ出すデータ用の描画 |
1 |
作品として表示する光の輪 |
2 |
Rubyを表示・復元するコードシート |
実際の世代交代で使うのは、モード2のコードシートです。
コードシートでは、一文字に横8ピクセル×縦9ピクセルの領域を割り当てています。
縦の位置 内容
0〜5 5×6ピクセルの字形
6 余白
7 文字のバイト値を示す8ビットの点列
8 余白
字形は、人がソースを見るためのものです。復元処理は、下に添えた8ビットの点列を読みます。
字形だけから読む場合、空白や改行を扱う必要があり、似た文字の判別も必要になります。各文字のバイト値を点列として描けば、これらを同じ方法で復元できます。
この作品で生成するRubyソースはASCIIで構成されています。そのため、一文字を一バイトとしてコードシートへ配置できます。
一文字を8個の点で表す
点列を描く処理は、この一行です。
if(gy==7)ink=float((c>>gx)&1u);
c は文字のバイト値、gx は文字領域内の横位置です。gx が0から7へ進むと、下位ビットから順に、そのビットが1か0かを調べます。
たとえば、A の値は65です。
通常の二進表記:01000001
描画する順序: 10000010
コードシートでは、左側を最下位ビットにしています。1の場所には明るい点を、0の場所には背景色を描きます。
空白は32、改行は10です。字形として見えない文字にも、その値に対応する点列があります。
各行には、文字の後に改行の点列も描きます。一行の最大幅200文字に改行の一文字分を加え、コードシートは201文字分、横1608ピクセルで描画しています。
GPUから表示Canvasへピクセルを渡す
コードシートは、WebGLのフレームバッファに接続した RGBA8 テクスチャへ描きます。
処理の流れを抜き出すと、次のようになります。描画先の設定などは省略しています。
gl.disable(gl.DITHER);
set('u_mode', 2);
gl.drawArrays(gl.TRIANGLES, 0, 3);
gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
readPixels で、GPUが描いた結果をバイト配列として受け取ります。
WebGLの読み出しは左下を原点とし、表示用のCanvasでは左上を原点として扱います。そのため、行の順序を上下反転してから、2D Canvasへ putImageData で転送します。
ソース画面に表示されるコードシートは、この描画結果です。
表示した点列からRubyを復元する
復元時には、表示用Canvasの getImageData でピクセルを読み直します。
一文字分の復元処理は、次のようになっています。
let byte = 0;
for (let bit = 0; bit < 8; bit++) {
const offset = ((row * 9 + 7) * w + col * 8 + bit) * 4;
if (pixels[offset] > 128) byte |= 1 << bit;
}
row * 9 + 7 は、対象行の点列がある縦位置です。col * 8 + bit は、対象文字の各ビットがある横位置です。
RGBAの4成分のうち赤の値を調べ、128より大きければ1として組み立てます。コードシートでは、明るい点と暗い背景の赤成分が、この閾値を挟むようになっています。
復元した値が10なら改行なので、その行の読み取りを終えます。想定していないゼロや、改行を見つけられない行はエラーにします。
最後に、集めたバイト列をUTF-8としてデコードします。
return new TextDecoder('utf-8', { fatal: true })
.decode(new Uint8Array(bytes));
この読み取り処理は、GLSLの SRC 配列や保存済みソース文字列を参照しません。文字の値は、表示Canvasのピクセルから組み立てています。
復元したRubyをブラウザ内で実行する
Rubyの実行には、ブラウザ内で動く ruby.wasm を使っています。この作品に組み込んだ処理系はCRuby 4.0です。
実行時には、Rubyの標準出力を StringIO へ差し替えます。そこへ復元したRubyの出力を受け取り、次の世代のソースとして取り出します。実行後には標準出力と ARGV を元に戻します。
世代交代の中心部分を抜き出すと、次の形です。
const drawn = checkCurrent();
const next = execute(drawn);
const built = bundle(execute(next, true));
install(built.fragment, built.manifest);
source = next;
drawCode();
checkCurrent();
checkCurrent() はピクセルからRubyを復元し、保持している現在のソースと一致するかを確認して、その復元結果を返します。
execute(drawn) で次のRubyを得て、execute(next, true) では --shader を付けて次のGLSLを作ります。新しいシェーダーを導入した後、コードシートを描き直し、その描画結果も検証します。
JavaScriptは処理の受け渡しを担当します。ソースに含まれる世代番号を進めるのは、実行されたRuby自身です。
glslkitは毎世代のシェーダーを処理する
Rubyが生成したGLSLは、ブラウザ内のglslkitへ渡します。
bundle = Glslkit::Bundle.build(
resolver: resolver,
name: 'ouroboros',
vertex: 'ouroboros.vert',
fragment: 'ouroboros.frag',
line_directives: false
)
ここで前処理・検証・リフレクションを行い、処理済みのシェーダーとmanifestを取得します。その後、WebGLでコンパイルとリンクを行います。
manifestには、uniformを更新するためのsetterが含まれています。JavaScriptはその情報から uniform2fv などを呼び、画面サイズやポインター位置、描画モードを設定します。
glslkitは初期シェーダーの配信にも使っていますが、作品の動作中も毎世代、新しく生成されたGLSLを処理しています。
光の輪も世代番号から変わる
作品画面には、48本の光の糸で作った輪が表示されます。その糸に描く文字も、ソースデータを取り出す B(i) から取得しています。
絵の変化に使う位相は、シェーダー内の世代番号 PHASE から作ります。
float t=float(PHASE)*6.2831853/24.;
この t が、糸のうねりや色、文字の配置などを変えます。世代交代で新しいGLSLが導入されると、次の姿になります。
画面サイズとポインター位置も描画へ渡しているため、見え方はそれらにも影響されます。同じ画面サイズとポインター位置で比較すれば、世代が一周すると描画に使う位相とソースデータも元に戻ります。
一周後に何を確かめるか
「一周を検証」では、ピクセルからの復元とRubyの実行を24回繰り返し、終了時のソースが開始時のソースと一致するかを調べます。
加えて、各世代で描いたコードの復元結果を、その世代のソースと比較しています。
確認するのは、見た目が似ていることだけではありません。改行や空白を含め、次の実行へ渡すコードが一致していることです。作品で生成するソースはASCIIなので、この文字列の一致はソースのバイト列の一致にも対応します。
リポジトリには、Chrome / WebGL2でのブラウザ検証結果も記録しています。Safari / Firefoxでの動作は未検証です。
自分を描き、その描画から次を生む
3本を通して、OUROBOROSを構成する処理を見てきました。
- コード本体を保持し、外枠を付けて再生成する。
- 世代番号から輪の形を計算し、本体を配置する。
- そのソースをGPUで描き、ピクセルから復元して実行する。
Quineとしての自己再生成は、Rubyが担っています。WebGLはそのコードを描き、JavaScriptは描画結果からコードを復元して、Ruby処理系へ渡します。
自分の中にあるコードを描き、その描画から次の自分を生み出す。24世代で閉じるRubyの循環に、描画と読み取りの経路を組み込んだ作品です。
参考・ソースコード
実装を読む場合は、RubyとGLSLを生成する build.rb、描画を担当する scene.frag.in、ピクセルの復元と世代交代を担当する public/gallery.js の順に追うと、この記事の流れと対応します。