自分のコードを輪の形に並べ、実行するたびに次の形を生み出す。周期Quine「OUROBOROS」は、24世代で最初のソースへ戻る作品です。
公開作品はこちらです。
前回は、eval と %w を使って、文字の配置と実行するコードを切り離す仕組みを解説しました。今回は、その文字をどこへ置くのかを見ていきます。
対象は、Rubyソースそのものの輪です。公開作品に表示される光の糸はGLSLで描いており、その描画については次回扱います。
絵の形と、そこへ置く文字を分ける
アスキーアート型のQuineを作るときには、二つの情報を分けて考えると分かりやすくなります。
- どの場所に文字を置くかという「形」
- その場所へ順番に置いていく「コード本体」
たとえば、次のような形を用意します。
#####
## ##
## ##
## ##
#####
# の場所へコードを一文字ずつ置き、空白はそのまま残します。形の情報は、文字を置く場所を 1、空白を 0 とした配列でも表現できます。
この方法を詳しく紹介しているのが、「RubyでうどんげQuine(とAA型Quineの作り方講座)」です。この記事では、AAの形を数値化してコード内に持ち、実行時に復元しています。
OUROBOROSも、形と文字を分けて扱います。ただし、形を作る方法には数式を使いました。各位置について「ここは輪の内側か」を計算し、その答えによって文字を置くか空白にするかを決めています。
200列×80行の紙面を用意する
実装では、輪を配置するために200列×80行の領域を使っています。
rows = Array.new(80) do |y|
Array.new(200) do |x|
# この位置に置く文字を決める
end.join.rstrip
end
y は行番号、x は列番号です。左上から右へ進み、一行終わったら次の行へ移ります。
.join で一行の文字列を作り、.rstrip で行末に余った空白を除きます。計算に使う領域は200列ですが、出力する各行の長さは輪の形によって変わります。
この領域の各位置を調べるため、まず座標を変換します。
xx = (x - 99.5) / 98
yy = (y - 39.5) / 38
99.5 と 39.5 は、それぞれ領域の中央です。これを引くと、中央を原点とする座標になります。
さらに横を 98、縦を 38 で割り、領域の端付近が、おおむね -1 から 1 の値になるようにします。
この変換により、列番号や行番号を直接使うよりも、輪の大きさを扱いやすくなります。文字セルの縦横比はフォントによって異なるため、実際に表示される輪の見え方は表示環境にも依存します。
中心からの距離で輪を作る
変換した座標から、中心までの距離を求めます。
rr = Math.sqrt(xx * xx + yy * yy)
これは三平方の定理による距離です。中心では 0、中心から離れるほど大きくなります。
たとえば、次の条件で文字を置くと輪になります。
on = rr < 0.97 && rr > 0.40
外側の半径より内側にあり、内側の半径より外側にある場所だけを選びます。
| 中心からの距離 | 配置 |
|---|---|
0.40 以下 |
中央の穴なので空白 |
0.40 より大きく、0.97 より小さい |
輪なので文字を置く |
0.97 以上 |
輪の外なので空白 |
ここまでなら、毎回同じ輪ができます。次に、輪の境界を少し変形させます。
角度によって輪の太さを変える
各位置が中心から見てどちらの方向にあるかを求めます。
ang = Math.atan2(yy, xx)
ang は中心からその位置へ向かう角度です。
OUROBOROSでは、この角度を使って内側と外側の境界を変えています。
outer = 0.97 + 0.025 * Math.cos(3 * ang + a)
inner = 0.40 + 0.035 * Math.sin(2 * ang + a)
on = rr < outer && rr > inner
外側は 0.97 を基準に 0.025、内側は 0.40 を基準に 0.035 の幅で揺れます。
3 * ang は輪を一周する間に三周期、2 * ang は二周期の変化を作ります。内外の境界が異なる周期で変化するため、場所によって輪の厚みが変わります。
輪を大きく崩さず、小さなうねりを付けるための式です。
世代番号を形の変化につなぐ
式に登場した a は、世代番号 k から作ります。
a = k * Math::PI / 12
一世代進むごとに、位相を π / 12、つまり15度分進めます。24世代分では 2π になり、三角関数の一周期に相当します。
ただし、この式は輪全体を一世代ごとに15度回転させるものではありません。外側には 3 * ang、内側には 2 * ang があるため、境界のうねりがそれぞれ異なる動きをします。
実際の世代番号は、次の式で 0 から 23 を循環します。
(n + 1) % 24
23の次では、番号そのものが0に戻ります。最初と同じ入力で形を計算し直すため、浮動小数点の計算で「一周した角度が厳密に元へ戻るか」に頼る必要もありません。
輪の中へコードを流し込む
形が決まったら、コード本体を一文字ずつ配置します。
実装では、配置する文字を次の式で用意しています。
v = ($s.split(35.chr)[0] + 35.chr).chars
35.chr は # です。
今回の本体では、最初の # が実行する処理の終わりに置かれています。その手前を取り出し、# を一つ付け直して、文字の配列にします。
この切り出しは、今回の本体の構造を前提とした処理です。文字列の中などに # を含む任意のRubyコードへ、そのまま使える方法ではありません。
各位置へ置く文字は、次の式で決めます。
on ? (v.shift || 35.chr) : 32.chr
32.chr は空白です。
- 輪の内側なら、
v.shiftで次の一文字を取り出す。 - 本体を使い切ったら、
#を置く。 - 輪の外側なら、空白を置く。
文字は紙面を左上から順番に走査して配置します。輪の円周に沿って並べる処理ではありませんが、文字を置く場所を輪の内側に限定することで、ソース全体が輪に見えるようになります。
余った場所と、足りない場所
輪の中には、コード本体の文字数より多くの配置場所があります。余った場所を埋めるのが # です。
前回紹介した外枠は、次の形でした。
eval$s=%w!
…輪の形に並べたコード…
!*""
%w と連結によって配置用の空白や改行を取り除くと、実行する本体の末尾に # があり、その後ろに埋め草の # が続く文字列になります。eval では、末尾の # 以降がコメントとして扱われます。
次の世代を作る際には、先ほどの split で埋め草を取り除きます。形によって埋め草の数が変わっても、再配置する本体は同じです。
逆に、輪が小さすぎてコードを置き切れなかった場合は、エラーにします。
raise "capacity" unless v.empty?
配置が終わった時点で v に文字が残っていれば、本体の一部が出力されていません。途中で切れたRubyを次の世代へ渡す前に、ここで止めます。
外枠を付ければ、次の世代になる
最後に、輪の形に並べた本体へ世代番号と実行用の外枠を付けます。
実装中の文字コードによる組み立てを、読みやすく書き直すと次の形です。
"n=0x%02x;eval$s=%%w!\n" % k +
rows.join("\n") +
"\n!*\"\"\n"
%02x は世代番号を二桁の16進数にします。%% は書式文字列の中で、文字としての % を出力するための記法です。
この式が返すのは、次のようなRubyソースです。
n=0x01;eval$s=%w!
…第1世代の輪に並べたコード…
!*""
配置する関数自身もコード本体に含まれています。そのため、出力されたRubyも同じ手順で次の世代を生成できます。
24枚の完成済みソースを切り替える必要はありません。各世代が持つ本体と世代番号から、その都度、次のソースを作ります。
コードと形が一緒に循環する
今回の輪を作る処理は、次の順序で動きます。
- 世代番号から、境界を変形させる位相を求める。
- 各位置の距離と角度から、輪の内側かどうかを判定する。
- 輪の内側へコード本体を順番に配置する。
- 余った場所を
#で埋め、世代番号と外枠を付ける。
同じ本体でも、世代番号によって配置が変わります。そして番号が0へ戻ると、同じ形と同じソースが再生成されます。
Quineの自己再生成と、数式による造形をつなぐ役割を担っているのが、この配置処理です。
次回:描いたコードを読み取って実行する
ここまでで、Rubyが自分のコードを輪の形に並べ直し、次の世代を生み出す仕組みを見てきました。
公開作品では、さらにそのRubyをGLSLへ埋め込み、GPUでコードシートを描きます。そして、描画したピクセルからRubyを復元して実行します。
次回は、コードの各バイトをシェーダーへ渡す方法と、空白や改行までピクセルから読み取れる仕組みを解説します。