WebGLでライフゲームしてみた

JavaScript Advent Calendar 2011 WebGL駅伝17日目になります。ほぼ独走状態でやってきましたが、明日から数日は書けそうにないと思うので、もしよかったら誰か代わりに書きませんか? id:ultraist さんとか id:ndruger さんとかいかがでしょうか(チラッ)。とりあえず明日は id:nakamura001 さんに書いていただけることになりました。

さて、表題のライフゲーム(Conway's Game of Life)というのは、ゲーミフィケーションソーシャルゲーム的な意味のゲームではなく、ゲーム理論的な意味のゲームです。ある条件に添って個々の構成単位がほぼ独立に振る舞うことによって、全体として何らかの現象が観測できる、という遊びです。

白マスを死んだセル(細胞)、黒マスを生きたセルとして、それぞれのセルはある時刻の次の時刻に以下のような状態に変化したりしなかったりします。

誕生
死んでいるセルに隣接する生きたセルがちょうど3つあれば、次の世代が誕生する。
生存
生きているセルに隣接する生きたセルが2つか3つならば、次の世代でも生存する。
過疎
生きているセルに隣接する生きたセルが1つ以下ならば、過疎により死滅する。
過密
生きているセルに隣接する生きたセルが4つ以上ならば、過密により死滅する。

こういった並列性の高い問題はGPUに持って来いです。

というわけでWebGLでやってみました。

実装

このAdvent Calendarの8〜10日目で、WebGLに2Dの描画をする方法を説明しました。それを見ながらやれば2時間ぐらいで簡単に作れます。

ソースは全部合わせても200行ぐらいです。

今回新しいところは、canvas要素を新しく作って、2D contextのImageDataオブジェクトをテクスチャーにしていることです。

テクスチャーを用意→それを使ってフレーム描画→描画したものをテクスチャーに保存→次のフレームを描画という流れです。

WebGLのテクスチャーとして使えるのは、img、canvas、それからImageDataオブジェクトだけです。

  var canvas = document.createElement('canvas');
  var ctx = canvas.getContext('2d');
  canvas.width = width;
  canvas.height = height;
  //document.body.appendChild(canvas); // デバッグ用。documentに追加しなくてもいい。

  var imdata = ctx.createImageData(width, height); // ImageData作る
  var i = 0;
  for (var y = 0; y < height; y++) {
    for (var x = 0; x < width; x++) {
      var color = (Math.random() > 0.2) * 255;
      imdata.data[i++] = color;
      imdata.data[i++] = color;
      imdata.data[i++] = color;
      imdata.data[i++] = 255;
    }
  }

...

  var inputTexture = gl.createTexture();

  gl.bindTexture(gl.TEXTURE_2D, inputTexture);
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, imdata); // テクスチャーにする
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);

それから、ターンの最後にWebGLで描画したものを再度ImageDataオブジェクトの中に書き戻さないといけません。それをやるのがreadPixelsメソッドです。

    gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixelarray);

さて、ここからが今日の本題です。

このpixelarrayとして使えるのは、Typed ArrayのArrayBufferViewのうちのどれかです。

すなわち、Float64Array、Float32Array、Uint32Array、Int32Array、Uint16Array、Int16Array、Uint8ClampedArray、Uint8Array、Int8Arrayになります。

Uint8ClampedArray

って何?と思う人も多いかもしれません。これは、Uint8Arrayと基本的に同じものですが、代入するときに0より小さければ0に、255より大きければ255に切られる(挟まれる)という違いがあります。

clampは挟むという意味です。

昔はこのクラスはありませんでした。なぜ導入されたのかというと、canvas 2D contextに昔からあったCanvasPixelArrayをなくしてTyped Arrayを使おうという流れから来たものです。ただ、CanvasPixelArrayはUint8Arrayとは上述の制限の違いがあったため、Uint8ClampedArrayが作られました。(↓このあたりから議論が始まった)

そういうわけで、上のコードはpixelarrayって書かなくても、↓だけでいいのです。

    gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, imdata.data);

ただし、現在のところUint8ClampedArrayを実装してるのはFirefoxだけです。

なのでFirefox以外は別にUint8Arrayを作って1要素ずつコピーしていかないといけません。それがこの処理です。

  var uint8ClampedArrayAvailable = 
    typeof Uint8ClampedArray !== "undefined" && imdata.data instanceof Uint8ClampedArray;

  var pixelarray;
  if (!uint8ClampedArrayAvailable) {
    pixelarray = new Uint8Array(width * height * 4);
  } else {
    pixelarray = imdata.data;
  }

...

    gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixelarray);
    if (!uint8ClampedArrayAvailable) {
      for (var i = 0, l = pixelarray.length; i < l; i++) {
        imdata.data[i] = pixelarray[i];
      }
    }

まとめ

そういうわけで今日のデモをFirefoxChromeで試すと、うちではExecution time per frameがChromeで17〜20msなのに対し、Firefoxでは8msぐらいです。(ただし、Chromeは書き戻しもさることながら、なぜかgl.texImage2Dでかなり時間を使ってました)

25万回の計算で8msって、どうなんでしょうかねえ。JavaScriptだけでも実装してみたらおもしろかったんでしょうが、面倒だったのでやってません。誰かやってみたら教えて下さい。


あと、ImageDataじゃなくて描画するcanvasを直接次のテクスチャーに指定したりしてみたんですが、なんかおかしなことになりました。Chromeでは動かず、Firefoxだと動くのは動くんですが、数十ターン一気に動いてるような…まあそういうことはやめておきましょうということで。

createFramebufferしたらいいじゃん、っていうツッコミは受け付けません。今日のはImageDataを使うための記事です。framebufferの話はまた今度書くかも。