WebGLでライフゲームを作る

WebGLを使ってライフゲームを作ったので、備忘録的に実装について書いておきます。

<img width="761" alt="lifegame.png" src="https://qiita-image-store.s3.amazonaws.com/0/61569/7402ef78-73fc-2af4-4906-e43858024bb3.png">


まず動いているものですが、Githubに置いたので以下のリンクから見ることができます。
https://aadebdeb.github.io/WebGL_LifeGame

WebGLを使うことで、１セルを１ピクセル平方に対応させて全画面で描画しても、サクサク動いていると思います。

やっていることは、概略すると以下のようになります。

1. 初期化用のシェーダーで、初期状態（ランダム状態）をテクスチャにオフスクリーンレンダリングする
2. 更新用のシェーダーで、１ステップ前の状態をもとに次の状態をテクスチャにオフスクリーンレンダリングする
3. 描画用のシェーダーで、2で作成したテクスチャを画面全体を覆う板ポリに貼り付けるように画面にレンダリングする
4. 2に戻る

3D的な演算はなく、すべてのレンダリングは画面全体を覆う板ポリに対して行うので、vertexシェーダーは1つだけ、fragmentシェーダーは初期化用・更新用・描画用の３つを使います。

以下、コメント解説付きのソースコードです。

```html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <title>Life Game by WebGL</title>
    <style>
      body {
        margin: 0;
        overflow: hidden;
      }
    </style>
  </head>
  <body>
    <canvas id="canvas"></canvas>

    <!--
      今回使う唯一のvertexシェーダー
      板ポリに描画するだけなので、座標変換などはしない
    -->
    <script id="vertex-shader" type="x-shader/x-vertex">
      attribute vec3 aPosition;
      attribute vec2 aTextureCoordinate; // テクスチャ座標

      varying vec2 vTextureCoordinate;

      void main() {
        vTextureCoordinate = aTextureCoordinate;
        gl_Position = vec4(aPosition, 1.0);
      }
    </script>

    <!--
      初期化用のfragmentシェーダー
      ランダムに各セルの状態を決定する
    -->
    <script id="initialize-shader" type="x-shader/x-fragment">
      precision mediump float;

      uniform vec2 uOffset; // ランダムシード、ここに試行ごとに異なる値を渡すことで異なる結果となる

      varying vec2 vTextureCoordinate; // このシェーダーでは使用しない

      float random(vec2 st) {
        return fract(sin(dot(st, vec2(12.9898,78.233))) * 43758.5453123);
      }

      void main() {
        // ランダムに生（緑色）と死（黒色）の状態を決定する
        gl_FragColor = random((gl_FragCoord.xy + uOffset) * 0.001) < 0.5 ? vec4(0.0, 1.0, 0.0, 1.0) : vec4(0.0, 0.0, 0.0, 1.0);
      }
    </script>

    <!--
      更新用のfragmentシェーダー
      自分自身と8近傍の状態から次の状態を決定する
    -->
    <script id="update-shader" type="x-shader/x-fragment">
      precision mediump float;

      uniform sampler2D aTexture; // 1ステップ前の状態を格納するテクスチャ
      uniform vec2 uResolution; // テクスチャの大きさ

      varying vec2 vTextureCoordinate; // このシェーダーでは使用しない

      void main() {
        vec2 scale = 1.0 / uResolution;

        // 自分の1ステップ前の状態を取得する、1ステップ前のgreenの値から状態を判断している
        bool isAlive = texture2D(aTexture, gl_FragCoord.xy * scale).g < 0.5 ? false : true;

        // ８近傍の生きているセル数をカウントする
        int sum = 0;
        sum += texture2D(aTexture, (gl_FragCoord.xy + vec2(-1.0,  1.0)) * scale).g < 0.5 ? 0 : 1;
        sum += texture2D(aTexture, (gl_FragCoord.xy + vec2( 0.0,  1.0)) * scale).g < 0.5 ? 0 : 1;
        sum += texture2D(aTexture, (gl_FragCoord.xy + vec2( 1.0,  1.0)) * scale).g < 0.5 ? 0 : 1;
        sum += texture2D(aTexture, (gl_FragCoord.xy + vec2(-1.0,  0.0)) * scale).g < 0.5 ? 0 : 1;
        sum += texture2D(aTexture, (gl_FragCoord.xy + vec2( 1.0,  0.0)) * scale).g < 0.5 ? 0 : 1;
        sum += texture2D(aTexture, (gl_FragCoord.xy + vec2(-1.0, -1.0)) * scale).g < 0.5 ? 0 : 1;
        sum += texture2D(aTexture, (gl_FragCoord.xy + vec2( 0.0, -1.0)) * scale).g < 0.5 ? 0 : 1;
        sum += texture2D(aTexture, (gl_FragCoord.xy + vec2( 1.0, -1.0)) * scale).g < 0.5 ? 0 : 1;

        // 自分自身と8近傍の状態から次の状態を決定する
        gl_FragColor = ((isAlive && (sum == 2 || sum == 3)) || (!isAlive && sum == 3)) ? vec4(0.0, 1.0, 0.0, 1.0) : vec4(0.0, 0.0, 0.0, 1.0);

      }
    </script>

    <!--
      描画用のfragmentシェーダー
      テクスチャを貼り付けるだけ
    -->
    <script id="render-shader" type="x-shader/x-fragment">
      precision mediump float;

      uniform sampler2D aTexture;

      varying vec2 vTextureCoordinate; // テクスチャ座標

      void main() {
        gl_FragColor = texture2D(aTexture, vTextureCoordinate);
      }
    </script>

    <script>

      // シェーダーを作成する
      // @param [WebGLRenderingContext] gl
      // @param [String] id - id of a element which has shader source
      // @return [WebGLShader]
      function createShader(gl, id) {
        const elem = document.getElementById(id);
        if (!elem) {
          throw new Error('Can not find element "'  + id + '"')
        }

        // type属性をもとに、シェーダーのタイプ（vertexかfragmentか）を判定
        const type = elem.type === 'x-shader/x-vertex' ? gl.VERTEX_SHADER :
                     elem.type === 'x-shader/x-fragment' ? gl.FRAGMENT_SHADER : null;
        if (!type) {
          throw new Error('Can not math shader type"' + elem.type + '"')
        }

        const shader = gl.createShader(type);
        gl.shaderSource(shader, elem.text);
        gl.compileShader(shader);

        if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
          throw new Error('Can not compile shader sourde (' + gl.getShaderInfoLog(shader) + ')');
        }

        return shader;
      }

      // プログラムを作成する
      // @param [WebGLRenderingContext] gl
      // @param [WebGLShader] vertexShader
      // @param [WebGLShader] fragmentShader
      // @return [WebGLProgram]
      function createProgram(gl, vertexShader, fragmentShader) {
        const program = gl.createProgram();

        gl.attachShader(program, vertexShader);
        gl.attachShader(program, fragmentShader);

        gl.linkProgram(program);
        if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
          throw new Error('Can not link program(' + gl.getProgramParameter(program) + ')');
        }

        return program;
      }

      // ArrayBufferを作成する
      // @param [WebGLRenderingContext] gl
      // @param [Array<Number>] array
      // @param [WebGLBuffer]
      function createArrayBuffer(gl, array) {
        const buffer = gl.createBuffer();
        gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
        gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(array), gl.STATIC_DRAW);
        gl.bindBuffer(gl.ARRAY_BUFFER, null);
        return buffer;
      }

      // attributesを設定する
      // @param [WebGLRenderingContext] gl
      // @param [Object] attributes
      function setAttributes(gl, attributes) {
        Object.keys(attributes).forEach((name) => {
          const attribute = attributes[name];
          gl.bindBuffer(gl.ARRAY_BUFFER, attribute.buffer);
          gl.enableVertexAttribArray(attribute.location);
          gl.vertexAttribPointer(attribute.location, attribute.size, gl.FLOAT, false, 0, 0);
        });
        gl.bindBuffer(gl.ARRAY_BUFFER, null);
      }

      // フレームバッファを作成する
      // 今回はすべて2Dなのでテクスチャだけを持たせている
      // @param [WebGLRenderingContext] gl
      // @param [Number] width
      // @param [Number] height
      // @return [Object]
      function createFrameBuffer(gl, width, height) {
        const frameBuffer = gl.createFramebuffer(),
              texture = gl.createTexture();

        gl.bindFramebuffer(gl.FRAMEBUFFER, frameBuffer);

        gl.activeTexture(gl.TEXTURE0);
        gl.bindTexture(gl.TEXTURE_2D, texture);
        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
        // 本当は以下のようにgl.REPEATで両端が繋がったトーラス空間にしたかったが、gl.REPEATはテクスチャが2の累乗じゃないダメらしい
　　　　　　　　　　　　　　　　// gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
        //gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
        gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);

        gl.bindTexture(gl.TEXTURE_2D, null);
        gl.bindFramebuffer(gl.FRAMEBUFFER, null);

        // テクスチャも別に使うので、frameBufferだけでなくtextureも返す
        return {
          frameBuffer: frameBuffer,
          texture: texture
        };
      }

      // =========================================================
      // ここからがメインの処理
      // =========================================================

      document.addEventListener('DOMContentLoaded', function() {

        // canvas要素の取得
        const canvas = document.getElementById('canvas');
        canvas.width = window.innerWidth;
        canvas.height = window.innerHeight;

        // contextの作成
        const gl = canvas.getContext('webgl');

        // シェーダーの作成
        const vertexShader = createShader(gl, 'vertex-shader');
        const initializeShader = createShader(gl, 'initialize-shader');
        const updateShader = createShader(gl, 'update-shader');
        const renderShader = createShader(gl, 'render-shader');

        // プログラムの作成
        const initializeProgram = createProgram(gl, vertexShader, initializeShader);
        const updateProgram = createProgram(gl, vertexShader, updateShader);
        const renderProgram = createProgram(gl, vertexShader, renderShader);

        // 頂点の作成（板ポリ用）
        const position = [
          -1.0,  1.0,  0.0,
          -1.0, -1.0,  0.0,
           1.0,  1.0,  0.0,
           1.0, -1.0,  0.0
        ];
        const positionBuffer = createArrayBuffer(gl, position);

        // テクスチャ座標の作成（板ポリ用）
        const texCoord =[
          0.0, 1.0,
          0.0, 0.0,
          1.0, 1.0,
          1.0, 0.0
        ];
        const texCoordBuffer = createArrayBuffer(gl, texCoord);

        // フレームバッファの作成
        let prevFrameBufferObj = createFrameBuffer(gl, canvas.width, canvas.height);
        let nextFrameBufferObj = createFrameBuffer(gl, canvas.width, canvas.height);

        // 初期化用のプログラムで、初期状態をテクスチャにオフスクリーンレンダリング
        gl.useProgram(initializeProgram);
        gl.bindFramebuffer(gl.FRAMEBUFFER, prevFrameBufferObj.frameBuffer);
        gl.viewport(0, 0, canvas.width, canvas.height);
        gl.clearColor(0.0, 0.0, 0.0, 1.0);
        setAttributes(gl, {
          aPosition: {
            location: gl.getAttribLocation(initializeProgram, 'aPosition'),
            size: 3,
            buffer: positionBuffer
          }
        });
        gl.uniform2fv(gl.getUniformLocation(initializeProgram, 'uOffset'), [Math.random() * canvas.width, Math.random() * canvas.height]);
        gl.clear(gl.COLOR_BUFFER_BIT);
        gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
        gl.flush();

        // 繰り返し処理を行うメソッド
        const render = () => {
          // 更新用のプログラムで次の状態をテクスチャにオフスクリーンレンダリング
          gl.useProgram(updateProgram);
          gl.bindFramebuffer(gl.FRAMEBUFFER, nextFrameBufferObj.frameBuffer);
          gl.viewport(0, 0, canvas.width, canvas.height);
          gl.clearColor(0.0, 0.0, 0.0, 1.0);
          gl.activeTexture(gl.TEXTURE0);
          gl.bindTexture(gl.TEXTURE_2D, prevFrameBufferObj.texture); // 一つ前の状態のテクスチャをバインド
          setAttributes(gl, {
            aPosition: {
              location: gl.getAttribLocation(updateProgram, 'aPosition'),
              size: 3,
              buffer: positionBuffer
            }
          });
          gl.uniform1i(gl.getUniformLocation(updateProgram, 'uTexture'), 0);
          gl.uniform2fv(gl.getUniformLocation(updateProgram, 'uResolution'), [canvas.width, canvas.height]);
          gl.clear(gl.COLOR_BUFFER_BIT);
          gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
          gl.flush();

          // 描画用のプログラムで画面にレンダリング
          // やっていることは更新用のプログラムで作成したテクスチャを板ポリに貼っているだけ
          gl.useProgram(renderProgram);
          gl.bindFramebuffer(gl.FRAMEBUFFER, null);
          gl.viewport(0, 0, canvas.width, canvas.height);
          gl.clearColor(0.0, 0.0, 0.0, 1.0);
          gl.activeTexture(gl.TEXTURE0);
          gl.bindTexture(gl.TEXTURE_2D, nextFrameBufferObj.texture); // 更新用のシェーダーにより作成したテクスチャをバインド
          setAttributes(gl, {
            aPosition: {
              location: gl.getAttribLocation(renderProgram, 'aPosition'),
              size: 3,
              buffer: positionBuffer
            },
            aTextureCoordinate: {
              location: gl.getAttribLocation(renderProgram, 'aTextureCoordinate'),
              size: 2,
              buffer: texCoordBuffer
            }
          });
          gl.uniform1i(gl.getUniformLocation(updateProgram, 'uTexture'), 0);
          gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
          gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
          gl.flush();

          // 今回描画したフレームバッファを、次の描画のために入れ替え
          let tmp = prevFrameBufferObj;
          prevFrameBufferObj = nextFrameBufferObj;
          nextFrameBufferObj = tmp;

          requestAnimationFrame(render)
        };

        // 繰り返しを開始する
        requestAnimationFrame(render);

      });
    </script>

  </body>
</html>
```