Open12

A City in a Bottleを分析する

kawarimidollkawarimidoll

これである

https://forest.watch.impress.co.jp/docs/serial/yajiuma/1413509.html

https://twitter.com/KilledByAPixel/status/1517294627996545024

<canvas style=width:99% id=c onclick=setInterval('for(c.width=w=99,++t,i=6e3;i--;c.getContext`2d`.fillRect(i%w,i/w|0,1-d*Z/w+s,1))for(a=i%w/50-1,s=b=1-i/4e3,X=t,Y=Z=d=1;++Z<w&(Y<6-(32<Z&27<X%w&&X/9^Z/8)*8%46||d|(s=(X&Y&Z)%3/Z,a=b=1,d=Z/w));Y-=b)X+=a',t=9)>

衝撃を受けたとしか言いようがない

過去にGenerative Artやっていた経験があるため、なんとなくわかるんじゃねーの?自分もできるんじゃない?と思い、無謀にも分析を試みた

いちおうこれを分解した記事が既に出ているが、細かい解説はされていない

https://observablehq.com/@darabos/decoding-a-city-in-a-bottle

kawarimidollkawarimidoll

まずtweetに載せられているコードは文字を減らすための工夫がされている

  • <canvas>要素は本来は閉じタグが必要だが、曖昧性がなければ閉じタグは省略できる(ブラウザが補完する)
  • html要素のクォートは省略できる
  • ループするためのsetIntervalをonclickで仕込んでいる
  • setIntervalの引数を関数でなく文字列としている
  • forループのカッコを省略している

このあたりを分解して一般的なコードに直すと以下のような形になる

<canvas style="width:99%" id="c"></canvas>

<script>
  function main() {
    for (
      c.width = w = 99, ++t, i = 6e3;
      i--;
      c.getContext`2d`.fillRect(i % w, i / w | 0, 1 - d * Z / w + s, 1)
    ) {
      for (
        a = i % w / 50 - 1, s = b = 1 - i / 4e3, X = t, Y = Z = d = 1;
        ++Z < w &
        (Y < 6 - (32 < Z & 27 < X % w && X / 9 ^ Z / 8) * 8 % 46 ||
          d | (s = (X & Y & Z) % 3 / Z, a = b = 1, d = Z / w));
        Y -= b
      ) {
        X += a;
      }
    }
  }
  setInterval(main, t = 9)
</script>

すこし一般的な(読めそうな)コードに近づいてきた
ここからは主に上記のmain関数の中を分析していく

kawarimidollkawarimidoll

先にmainの外について

setInterval(main, t = 9)

ここでやっていることは

  • mainを10msごとに呼び出す
  • t = 9を初期値とする

setIntervalの第2引数は呼び出し間隔だが、10未満は10として扱われるので、ここは完全にグローバル変数tの初期化のために使われている

https://developer.mozilla.org/ja/docs/Web/API/setInterval

つまり実質こう

t = 9
main () {
  // 略
}
setInterval(main, 10)
kawarimidollkawarimidoll

ではmainに切り込んでいこう
外側のforループに着目すると、以下の構造が見えてくる
forの条件式の部分でデクリメントをしているあたりがやばいね…

for (
  c.width = w = 99, ++t, i = 6e3;
  i--;
  c.getContext`2d`.fillRect(i % w, i / w | 0, 1 - d * Z / w + s, 1)
) {
  for ( /* 略 */ ) {
    X += a;
  }
}

初期化式をforの外に、加算式をforのブロック内に移動させ、条件式を分割し、getContext...の部分をわかりやすく修正すると、以下の形になる

c.width = 99;
w = 99;
++t;

for (i = 6000; i > 0; i--) {
  for ( /* 略 */ ) {
    X += a;
  }
  c.getContext('2d').fillRect(i % w, Math.floor(i / w), 1 - d * Z / w + s, 1)
}

whileループにしてもよいが、一旦ここまで

kawarimidollkawarimidoll

続いて内側のforループ これは条件式の部分がやべーので一旦抽象化する

for (
  a = i % w / 50 - 1, s = b = 1 - i / 4e3, X = t, Y = Z = d = 1;
  ++Z < w & cond;
  Y -= b
) {
  X += a;
}

これも分割する こっちはwhileにしたほうがスジが良いかと思った

a = i % w / 50 - 1;
b = 1 - i / 4000;
s = b
X = t;
Y = 1;
Z = 1;
d = 1;
while (Z < w & cond) {
  X += a;
  Y -= b;
  Z++;
}

ここまででmainの中身はこうなる

c.width = 99;
w = 99;
++t;

for (i = 6000; i > 0; i--) {
  a = i % w / 50 - 1;
  b = 1 - i / 4000;
  s = b
  X = t;
  Y = 1;
  Z = 1;
  d = 1;
  while (Z < w & cond) {
    X += a;
    Y -= b;
    Z++;
  }
  c.getContext('2d').fillRect(i % w, Math.floor(i / w), 1 - d * Z / w + s, 1)
}

なんとなく見えてきた

kawarimidollkawarimidoll

fillRectの部分を確認しよう

fillRectはcanvasに座標と大きさを指定して矩形を描画する関数で、次の4つの引数をとる

  • 始点X座標
  • 始点Y座標
  • 幅(X方向サイズ)
  • 高さ(Y方向サイズ)

https://developer.mozilla.org/ja/docs/Web/API/CanvasRenderingContext2D/fillRect

今回は以下のように呼び出されている

fillRect(i % w, Math.floor(i / w), 1 - d * Z / w + s, 1)

第一・第二引数は変数ひとつで二次元座標をループさせるテクニック
普通に考えると、二次元の画面を走査するには、以下のように2重ループが必要に思える

width = 100
height = 100
for (y = 0; y < height; y ++) {
  for (x = 0; x < width; x ++) {
    // do something
  }
}


横一列をループし、それを縦に繰り返していく

しかし、剰余と除の整数化を使うことで、1つのループで二次元画面を走査できるようになる

width = 100
height = 100
for (i = 0; i < width * height; i ++) {
  x = i % width
  y = Math.floor(i / width)
  // do something
}


横一列をループし、端まで行ったら折り返していく

また、fillRectはfillStyleで設定された値に応じて矩形を描画する
初期値は#000なので、スタイル設定をせずに使うと基本的には真っ黒な四角が表示されるだけ

https://developer.mozilla.org/ja/docs/Web/API/CanvasRenderingContext2D/fillStyle

しかし今回は第三引数(幅)として1 - d * Z / w + sが渡されており、これは後ほど検討するが小さい値になる
幅1未満の矩形を描画しようとすると、色が薄く表示されるため、ここの値を調整することで(モノクロだけど)濃淡のついた矩形を出力できる

kawarimidollkawarimidoll

では最もしんどい内部ループの条件式を見ていこう
ここが当たり判定と変数の更新を行っている

++Z < w &
(Y < 6 - (32 < Z & 27 < X % w && X / 9 ^ Z / 8) * 8 % 46 ||
  d | (s = (X & Y & Z) % 3 / Z, a = b = 1, d = Z / w))

まず大きな構造は++Z < w & cond
ここは前半・後半ともに真のときにループ継続したいものの、前半が偽でもショートサーキットしたくないためにビットANDが使われている…と思われる

そして上記のcond(判定部) || (処理部)のようにOR演算子で繋がれている
前半の判定部が偽に評価されれば、後半の処理部が実行されるかたち

さらに処理部はd | (s = (X & Y & Z) % 3 / Z, a = b = 1, d = Z / w)となっている
カンマ演算子は「複数の式を実行して最後の結果を返す」から、カッコ内のs = (X & Y & Z) % 3 / Z, a = b = 1, d = Z / wが順次実行される つまりループ内での再代入の役割を果たしている
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/Comma_Operator

で、カッコでくくられた部分の評価結果はZ / wになるから、ここ全体はd | (Z / w)として評価され、これはdZ / wの両方が1未満のときに偽として評価される(たぶん)
カッコ内の最後の式がd = Z / wなので、結局d | dになりそうだが、ビットORが代入より優先度が高いためにそうはならない…のか? ここちょっと確認が必要
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/Operator_Precedence#一覧表

いくつか上のコメントで、内部ループは以下の形になっていた

while (Z < w & cond) {
  X += a;
  Y -= b;
  Z++;
}

今回わかったことを踏まえてcondを書き下すと以下のようになる

while (Z < w) {
  if (!(Y < 6 - (32 < Z & 27 < X % w && X / 9 ^ Z / 8) * 8 % 46)) {
    last = d;
    s = (X & Y & Z) % 3 / Z;
    a = 1;
    b = 1;
    d = Z / w;
    if (last < 1) break;
  }
  X += a;
  Y -= b;
  Z++;
}

ifの条件式に否定がついているのは、前述の通り「ここが偽のときにその後が実行される」ため

lastの解釈に関しては既存の解説記事を参考にした
https://observablehq.com/@darabos/decoding-a-city-in-a-bottle

kawarimidollkawarimidoll

ではifの条件式について更に見ていこう マジックナンバーの意味については、再び解説記事をかなり参考にする

https://observablehq.com/@darabos/decoding-a-city-in-a-bottle

これによると以下の通り展開できる
なお、演算子の優先順序を明示するためにカッコを追加した

groundPlane = 6
distanceToCity = 32
avenueWidth = 27
distanceBetweenAvenes = 99
buildingWidth = 9
buildingDepth = 8
buildingMaxHeight = 45

if (
  !(Y <
    groundPlane -
      (
        (
          (
            ((distanceToCity < Z) & (avenueWidth < (X % w))) &&
            ((X / buildingWidth) ^ (Z / buildingDepth))
          ) * 8
        ) % buildingMaxHeight
      ))
) {
// 略
}

以下、比較部をひとつひとつ見ていこう

distanceToCity < Z

Z座標が「街までの距離」より大きいときにtrue

avenueWidth < (X % w)

「街路の幅」が「画像幅による折返しを考慮したX座標」より小さければtrue
(剰余で折り返しを表現できるのは2つ前のコメント参照)

((distanceToCity < Z) & (avenueWidth < (X % w)))

上記2つがどちらもtrueのときtrue
(一方がfalseならfalseになり、短絡評価が発生)

((X / buildingWidth) ^ (Z / buildingDepth))

ここがむずい
両辺が論理演算ではない普通の除算で、それら同士のビットXORなので何が何やら
結果的に0かどうかが問題になるわけだが…
解説記事だとThe random building height comes from (X^Z)*8.とコメントがあるので、ここのパートが本質なのかもしれん

((distanceToCity < Z) & (avenueWidth < (X % w))) && ((X / buildingWidth) ^ (Z / buildingDepth))

上記の論理演算のANDを取る
式全体の評価値は((X / buildingWidth) ^ (Z / buildingDepth))となる

(上の結果) * 8

一つ上の結果を8倍 これはマジックナンバーぽい
ここ全体の評価値は((X / buildingWidth) ^ (Z / buildingDepth)) * 8
解説記事のコメントを鑑みると、これがビルの高さに相当すると思われる

(上の結果) % buildingMaxHeight

ビルの高さがMaxを超えていたときに折り返している処理 たぶん

!(Y < groundPlane - (上の結果))

groundPlaneは地面の高さで、上の結果は「折り返されたビルの高さ」
ここで「高さ」というのはY座標のことだから、地面のY座標とビルのY座標の差分は、「地面からのビルの高さ」になる ※ややこしいが、地面の座標を引かなければ、画面端からのビルの高さとなる
また、Yは1からデクリメントされていくから、負の値を取っている
これを考慮すると、Y2 = -Yを導入して、

!(Y < 地面高さ - ビル高さ)
!(Y2 < ビル高さ - 地面高さ)
Y2 >= ビル高さ - 地面高さ
Y2 >= 地面からのビルの高さ

座標の方向が判然としないけど、つまりここの条件式は「着目している(画面上での)Y座標にはビルがあるか」を判定しているんだ おそらくきっとなんとなく

kawarimidollkawarimidoll

マジックナンバーの8を1にしてみたところ低いビルが増えた でもたまに高いやつも流れてくるんだよなあ…

解説のコメントにThe random building height comes from (X^Z)*8.とあるとはいえrandom()を使っているわけではないからランダム性はないはず
単に「バラけて出てくる」程度の意味と思われる

kawarimidollkawarimidoll

i == 3000fillStyle = 'red'を入れてみた図
前述の通り折返しで横方向にループしていることを確認

内部ループのif文内部にfillStyle = 'red'を入れてみた図
やっぱり当たり判定として機能しているようだ

kawarimidollkawarimidoll

解説記事だとsはtextureと書かれているけどs !== 0のときに赤をつけてみたらちょっとイメージと違う

でも&を使うことで一定のステップで区切るために使われているようだ
https://twitter.com/kawarimidoll/status/1533000220333981697

heightの^は法則性が見にくくて難しい…Zで割っているのは遠距離ほど明るくするために使われているようだ

kawarimidollkawarimidoll

結局アタリ判定の部分を解析しきれなかったな
圧縮されていることを除いてもコード自体がかなり高難度だった
いつか理解できるようになったらリベンジしたい…