Rubyで、自分のコードを描いて、その描画から次の自分を生み出すQuineを作った。
24世代で元のコードへ戻る。ブラウザの中でRubyが動く。光の輪にコードが流れる。描いたピクセルから、本当にRubyを読み取って実行する。
これは見てもらいたい。
そう思って公開したら、スマホでエラーになった。
修正した。その後、LINE経由なら開けるのに、X経由だと開けないことに気づいた。
評価してもらう以前に、作品を見てもらえていなかった。悲しい。
今回は、RubyのQuineを公開してから、Androidの描画エラーとブラウザのキャッシュに向き合った話です。
作ったもの:OUROBOROS
通常のQuineは、自分自身のソースコードを出力するプログラムです。
今回作ったのは、一回の実行で次の世代を出力し、24回の実行で最初のソースへ戻る周期Quineです。
Ruby₀ → Ruby₁ → … → Ruby₂₃ → Ruby₀
各世代のRubyは輪の形に並んでいて、世代番号に応じて配置が少しずつ変わります。次のRubyを出力する処理も、そのRuby自身に含まれています。
さらに、ブラウザでは次の経路で世代を進めています。
Ruby → GLSL → GPUでコードを描く
↓
ピクセルからRubyを復元
↓
ruby.wasmで実行
↓
次のRuby
人が読む字形に加えて、各文字の下に8ビットの点列を描いています。この点列から、空白や改行も含めてソースを復元します。
復元したRubyを実行して次の世代を得た後、そのRubyを --shader モードで実行して次のGLSLを生成します。glslkitで処理し、WebGL2へ渡して次の姿を描きます。
ソースとGPU描画を往復する仕組みが、今回の作品の中心です。
最初の問題:スマホでコードを読めなかった
AndroidのChromeで開くと、次のエラーが出ました。
作品エラー:描画コードを読み取れません: 25,59
この位置は、コードシートの行と列です。内部では0から数えています。
復元処理は、各文字の8個の点を読み、明るければ1、暗ければ0として一バイトを組み立てます。
let byte = 0;
for (let bit = 0; bit < 8; bit++) {
if (pixels[start + bit * 4] > 128) {
byte |= 1 << bit;
}
}
今回のソースに、文字としてのゼロバイトは含まれていません。ゼロが出たら、描画か読み取りに問題があると判断して止めます。
つまり、このエラーはRubyの実行に到達する前に出ていました。
デスクトップで通った検証だけでは足りなかった
デスクトップのChromeでは、ピクセルからのコード復元も、24世代の循環も通っていました。ChromeのMetal描画経路でも検証しました。
しかし、Androidの実機では失敗しました。
ブラウザの画面幅をスマホに合わせても、実行しているGPUやドライバーはデスクトップのままです。スマホ幅の検証はレイアウト確認には使えますが、今回のGPUの問題を再現するには十分ではありませんでした。
ここで、エラー表示に診断情報を追加しました。
最初に見えたのは、8個の点が全部背景色だったことです。
赤成分: 7,7,7,7,7,7,7,7
閾値は128なので、これでは8ビットすべてが0になります。
ただし、この情報だけでは、GPUが描かなかったのか、表示用Canvasへ転送したときに変わったのかは分かりません。
そこで、比較する場所を増やしました。
| 診断情報 | 確認すること |
|---|---|
| 表示Canvasの点列 | 実際に復元処理が読んだ色 |
| GPUの点列 | Canvasへ転送する前の描画結果 |
| GPUから直接読んだバイト | 字形や点列の描画を経由せず、ソースの値を取得できるか |
| 期待バイト | その位置にあるはずの値 |
| GPU名 | どの描画環境で起きたか |
この追加情報で、Canvasへの転送前からデータが正しく取れていないことが見えてきました。
配列を分けても、まとめても、まだ失敗した
当初、Rubyのソースを4バイトずつ32ビット整数に詰め、GLSLの定数配列に入れていました。
まず、この大きな配列を小さなブロックに分割しました。
それでも失敗したため、コードシートの描画を、光の輪の描画とは別のWebGLプログラムに分離しました。
さらに、整数を4個ずつ uvec4 にまとめ、配列の要素数を約4分の1に減らしました。
const uvec4 SRC0[256] = uvec4[](...);
この変更後も、実機ではエラーになりました。ただし、失敗する位置が変わり、次の情報が得られました。
描画コードを読み取れません: 14,60
GPUバイト: 0
期待バイト: 40
GPU: ANGLE (Qualcomm, Adreno (TM) 710, OpenGL ES 3.2)
期待値40は、ASCIIの ( です。
表示Canvasだけでなく、GPU側で直接読んだ値も0になっています。シェーダー内の定数データ取得が疑わしい状況でした。
ただし、この観測だけでAdrenoのドライバー内部の原因まで断定したわけではありません。配列のサイズ、アクセス方法、シェーダーの処理を変えても実機で失敗したため、その取得経路を避ける方針にしました。
整数テクスチャでデータを渡す方式に変更した
最終的には、生成したGLSLに埋め込まれているデータをJavaScriptで取り出し、整数テクスチャとしてGPUへ転送する方式に変更しました。
転送するのは、次の三つです。
- Rubyソースの各バイト
- 各行の開始位置
- 字形を描くためのフォントデータ
テクスチャの形式は R32UI です。
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.R32UI,
width,
height,
0,
gl.RED_INTEGER,
gl.UNSIGNED_INT,
data
);
シェーダーは、texelFetch で整数を取得します。
uniform highp usampler2D u_data;
uint T(int i) {
return texelFetch(u_data, ivec2(i & 255, i >> 8), 0).r;
}
テクスチャの横幅は256です。インデックスの下位8ビットを横位置、それより上のビットを縦位置に使います。
ソースの各バイトを一つのテクセルに持たせたので、ソース取得時には32ビット整数から対象のバイトを取り出す処理も避けられます。
ここで、作品として大切な部分は維持しました。
次のRubyを実行するときには、GPUが描いたコードシートのピクセルから復元したRubyを使います。
保存済みのソース文字列を、読み取り失敗時の代わりとして実行する仕組みにはしていません。保存済みソースは、復元結果の比較とエラー診断に使っています。
テクスチャから本当に描いていることも検証した
テクスチャを導入しただけで安心するのではなく、GPUへ渡した値を意図的に変更する検証を加えました。
最初のバイトを変えると、描いたコードから復元する文字も変わる。元の値へ戻すと、ソース全体が再び一致する。
この検証により、テクスチャのデータが実際のコード描画へ使われていることを確認しました。
変更後は、デスクトップのChromeで次の検証が通りました。
- Rubyの3テスト・150アサーション
- コードの復元、24世代循環、単体ビューアなど、ブラウザ上の27項目
ただし、これで実機の問題まで解消したと即断はできません。公開して、実際に失敗していた端末で開く必要がありました。
ところが、LINEでは開けるのにXでは開けない
整数テクスチャ版を公開した後、LINE経由では開けるのに、X経由では以前と同じ 25,59 のエラーになることが分かりました。
ここで、もう一度シェーダーを書き直す前に、公開サイトの配信内容とHTTPヘッダーを確認しました。
公開サイトからは最新のHTMLが返っていました。
そして、レスポンスには次の設定がありました。
Cache-Control: max-age=86400
86400秒は24時間です。キャッシュは、この期間、保存したレスポンスを新しいものとして扱えます。
今回の静的版は、JavaScriptやRubyランタイムをHTMLへ埋め込んだ単体ファイルです。古いHTMLが残っていると、古い描画処理も一緒に残ります。
修正したファイルを公開しても、すでにそのURLを開いた環境では、すぐに新しい内容を取りに行くとは限りません。
LINEとXの内部ブラウザの詳細までは特定していませんが、開く環境や保持しているキャッシュが異なれば、同じ作品でも見える版が違う可能性があります。
更新版のURLにしたら開けた
そこで、更新版を識別するクエリを付けたURLで開き直しました。
https://glslkit-quine.lolipop-now.app/?v=9c7a112
その結果、X経由でも開けました。
今回の観測から、最後に残っていた問題は、X側で旧版のキャッシュが使われていた可能性が高いと考えています。
最初のスマホの描画エラーと、修正後にX経由で残っていた問題は、分けて考える必要がありました。
| 段階 | 観測 | 対応 |
|---|---|---|
| 最初のAndroid実機 | GPUから取得したソースの値が0になる | データ取得を整数テクスチャへ変更 |
| 修正公開後のX経由 | 以前の位置のエラーが残る一方、LINEでは開ける | 更新を識別するURLで開き直す |
後者だけを見て「Quineがまだ壊れている」と判断すると、古いコードを見ている環境のために、新しいコードを修正し続けることになってしまいます。
今回学んだこと
今回いちばん身に染みたのは、公開した作品は、自分の手元のブラウザだけで評価されるわけではないということです。
実機のGPUで動くか。共有したリンクから最新の版を開けるか。SNS経由でも作品に到達できるか。
そこまで含めて、見てもらうための準備でした。
次に同じような作品を公開するときは、次の点を意識したいです。
- 実際のスマホで、共有に使うSNSからリンクを開く。
- エラー時に、失敗位置・期待値・GPU名などを確認できるようにする。
- ソースの復元、Rubyの実行、描画、配信の問題を分けて調べる。
- HTMLのキャッシュ設定を確認し、更新時には版を識別できる共有URLを使う。
今回の対応では、キャッシュヘッダー自体は変更していません。更新版のURLを共有することで、旧版を参照する問題を避けました。
ようやく、見てもらえるようになった
Quineを作ったときは、自分を再生成するコードや、GPUからRubyへ戻る仕組みのほうに意識が向いていました。
公開したら、最後に立ちはだかったのはキャッシュでした。
ドヤ顔で公開した作品が開けないのは悲しい。でも、開けるようになったら、ようやく作品そのものを見てもらえます。
ソースコード
今回のデータ転送処理は public/shader-data.js、ピクセルからの復元と世代交代は public/gallery.js、描画は scene.frag.in にあります。