マンデルブロ集合をWebGLで描画してみる

この記事は Misoca+弥生 Advent Calendar 2019 の3日目です。

はじめに

12月に入り、だいぶ寒くなりましたね。今年は紅葉が遅いと聞いていたので、紅葉狩りがてら犬山城を観に行ってきました。 犬山城ファンなので行った時は必ず写真を撮るのですが、ことのときは「アドベントカレンダー何書こうかな。WebGL使いたいな。」ってぼんやり考えながら撮り続けてました。

f:id:ryotaway:20191202231415j:plain:w500
木曽川から撮った犬山城

で、撮った写真をみてて思ったんですよね。

マンデルブロ集合の描画をやろうって。*1

というわけでやりました。

マンデルブロ集合?

マンデルブロ 集合が何かわからない人のために、wikipediaのページへのリンクを貼っておきます。 ただし数学が大好きな人以外はリンク先へ飛ぶ必要はありません。青っぽい色した画像を眺めていただければそれで十分です。

ja.wikipedia.org

今回は、この青っぽい画像と同じような出力結果を得られればよしとすることにしました。

WebGL

WebGL Overview - The Khronos Group Inc によれば、

WebGL is a cross-platform, royalty-free web standard for a low-level 3D graphics API based on OpenGL ES, exposed to ECMAScript via the HTML5 Canvas element.

というわけで、ブラウザで3Dグラフィクスを扱うための仕様です。W3Cに仕様があるのかと思っていたけど違うんですね。知りませんでした。これを機に詳しくなっていこうと思います。

今回WebGLを学ぶ上でいくつかのサイトや書籍を参照しました。たとえば、

WebGL 開発支援サイト wgld.org は非常に丁寧に解説があり、WebGLをこれから勉強する人にとてもオススメなサイトです。 勉強中にコードを写経していた影響で、後述する私の実装が非常に似通ってしまっています。その点はごめんなさい。

また、 WebGL Insights 日本語版 は海面の表現や、グラフィクスエンジンの設計、Mozillaでの実装などが取り上げられていて興味深かったです。 少し高価かなと思いますがオススメです。

まずは結果

このような画像を生成できました。

f:id:ryotaway:20191203010226p:plain:w300
まぁまぁ近い色合い

f:id:ryotaway:20191203011810p:plain:w300
小さい円と大きい円のくっついているところを拡大したもの。タツノオトシゴの谷と呼ばれているそう。

実装

実装は html と javascript の2ファイルです。 手元で試したい人は、それぞれ index.html , script.js と名付けて同じディレクトリに配置してください。 index.html をブラウザから開けばマンデルブロ 集合が表示されると思います。 (githubリポジトリ作るまでもないかなと思ったので...)

html

実装は長いので折りたたんでおきます

<!DOCTYPE html>
<html>
  <head>
    <title>mandelbrot set</title>
    <script id="fragment" type="shader">
      precision mediump float;
      uniform vec2 resolution;

      vec3 hsv2rgb(float h, float s, float v) {
        return ((clamp(abs(fract(h + vec3(0.01, 2.0 ,1.0) / 3.0) * 6.0 - 3.0) - 1.0, 0.0, 1.0) - 1.0) * s + 1.0) * v;
      }

      void main() {
        vec2 p = (gl_FragCoord.xy * 2.0 - resolution) / min(resolution.x, resolution.y);
        vec2 x = p + vec2(-0.50, 0.0);
        float y = 1.25;
        vec2 z = vec2(0.0, 0.0);

        for (int i = 0; i < 80; i++) {
          z = vec2(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y) + x * y;
          if (length(z) > 2.0) {
            vec3 rgb = hsv2rgb(
              0.68 - 0.40*float(i)/80.0,
              1.0,
              0.6 + 1.20*float(i)/80.0
            );
            gl_FragColor = vec4(rgb, 1.0);
            return;
          }
        }
        gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
      }
    </script>

    <script id="vertex" type="shader">
      attribute vec3 position;
      void main() {
        gl_Position = vec4(position, 1.0);
      }
    </script>

    <script src="script.js" type="text/javascript"></script>

    <style type="text/css">
      body {
        text-align: center;
        margin: 20px auto;
        padding: 0;
      }
    </style>
  </head>
  <body>
    <canvas id="canvas"></canvas>
  </body>
</html>

javascript

実装は長いので折りたたんでおきます

var gl_context;
var uniform_locations = [];

const CANVAS_WIDTH = 512;
const CANVAS_HEIGHT = 512;
const ORIGIN_X = 0.5;
const ORIGIN_Y = 0.5;

window.onload = function() {
    var canvas = get_canvas(canvas);
    gl_context = get_context_from_canvas();

    var prg = create_program(create_shader('vertex'), create_shader('fragment'));
    uniform_locations[2] = gl_context.getUniformLocation(prg, 'resolution');

    var attrib_location = gl_context.getAttribLocation(prg, 'position');
    gl_context.bindBuffer(gl_context.ARRAY_BUFFER, create_vbo(
        [
            -1.0,  1.0,  0.0,
             1.0,  1.0,  0.0,
            -1.0, -1.0,  0.0,
             1.0, -1.0,  0.0,
        ]));
    gl_context.enableVertexAttribArray(attrib_location);
    gl_context.vertexAttribPointer(attrib_location, 3, gl_context.FLOAT, false, 0, 0);
    gl_context.bindBuffer(gl_context.ELEMENT_ARRAY_BUFFER, create_ibo(
        [
            0, 2, 1,
            1, 2, 3
        ]));

    gl_context.clearColor(0.0, 0.0, 0.0, 1.0);
    render();
};

function render() {
    gl_context.clear(gl_context.COLOR_BUFFER_BIT);
    gl_context.uniform2fv(uniform_locations[1], [ORIGIN_X, ORIGIN_Y]);
    gl_context.uniform2fv(uniform_locations[2], [CANVAS_WIDTH, CANVAS_HEIGHT]);

    gl_context.drawElements(gl_context.TRIANGLES, 6, gl_context.UNSIGNED_SHORT, 0);
    gl_context.flush();
}

function create_shader(id) {
    var elem = document.getElementById(id);
    var shader = get_shader(id)
    if (shader == null) {
        return null;
    }
    
    gl_context.shaderSource(shader, elem.text);
    gl_context.compileShader(shader);

    if (gl_context.getShaderParameter(shader, gl_context.COMPILE_STATUS)) {
        return shader;
    } else {
        console.log(gl_context.getShaderInfoLog(shader));
    }
}

function create_program(vs, fs) {
    var program = gl_context.createProgram();

    gl_context.attachShader(program, vs);
    gl_context.attachShader(program, fs);
    gl_context.linkProgram(program);
    
    if (gl_context.getProgramParameter(program, gl_context.LINK_STATUS)) {
        gl_context.useProgram(program);
        return program;
    } else {
        return null;
    }
}

function create_vbo(data) {
    var vbo = gl_context.createBuffer();
    gl_context.bindBuffer(gl_context.ARRAY_BUFFER, vbo);
    gl_context.bufferData(gl_context.ARRAY_BUFFER, new Float32Array(data), gl_context.STATIC_DRAW);
    gl_context.bindBuffer(gl_context.ARRAY_BUFFER, null);
    return vbo;
}

function create_ibo(data) {
    var ibo = gl_context.createBuffer();
    gl_context.bindBuffer(gl_context.ELEMENT_ARRAY_BUFFER, ibo);
    gl_context.bufferData(gl_context.ELEMENT_ARRAY_BUFFER, new Int16Array(data), gl_context.STATIC_DRAW);
    gl_context.bindBuffer(gl_context.ELEMENT_ARRAY_BUFFER, null);
    return ibo;
}

function setup_canvas(canvas) {
    canvas.width = CANVAS_WIDTH;
    canvas.height = CANVAS_HEIGHT;
}

function get_canvas() {
    canvas = document.getElementById('canvas');
    setup_canvas(canvas);
    return canvas
}

function get_context_from_canvas() {
    return canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
}

function get_shader(id) {
        switch (id) {
        case 'vertex':
            return gl_context.createShader(gl_context.VERTEX_SHADER);
        case 'fragment':
            return gl_context.createShader(gl_context.FRAGMENT_SHADER);
        default :
            return null;
        }
}

実装のポイント

index.htmlにあるfragment shaderの次の箇所が実装がポイントです。

       for (int i = 0; i < 80; i++) {
          z = vec2(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y) + x * y;
          if (length(z) >= 2.0) {
            vec3 rgb = hsv2rgb(
              0.68 - 0.40*float(i)/80.0,
              1.0,
              0.6 + 1.20*float(i)/80.0
            );
            gl_FragColor = vec4(rgb, 1.0);
            return;
          }
        }
        gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);

マンデルブロ集合とは、複素数列の漸化式

  •  Z_{n+1}=Z_{n}^2+C
  •  Z_{0}=0

のnを無限大に飛ばした時に値が収束する複素数の集合のことをいいます。

この Z_{n} を実数平面上点 Z_{n} ( X_{n} , Y_{n} )に置くことにすると、漸化式は次のようになります。

  •  X_{n+1}=X_{n}^2-Y_{n}^2+a
  •  Y_{n+1}=2X_{n}Y_{n}+b

これが発散するか収束するかをチェックすればよいことになります。 発散する場合はマンデルブロ 集合に含まれません。収束する時は含まれます。

ある点がマンデルブロ集合に含まれないときは、  |Z_{n}|=\sqrt {X_{n}^2+Y_{n}^2} が 2以上になることがわかっているそうです。 nを無限大に飛ばすことはできないので80に留めることとし、最大  |Z_{80}| まで計算して2以上になることがあるかを調べます。

よって次のコードとなるわけです。

 for (int i = 0; i < 80; i++) {
  z = vec2(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y) + x * y;
    if (length(z) >= 2.0) {
      ...

rgbは色を計算する箇所なので、いろいろ変更してみると楽しいと思います。

vec3 rgb = hsv2rgb(...)

上述したコードは、 計算した回数が多いと色が明るくなるようにしています。 また、マンデルブロ 集合に含まれている点は黒にしています。

まとめ

WebGLを使ってマンデルブロ 集合の画像を生成しました。

いつかやりたいなと思っていたマンデルブロ 集合だったので楽しく調査・実装することができました。 今回の記事では触れませんでしたが、GPGPUを使ってやりたいことがあるので、徐々にでも慣れて思いどおりに操れるようになるといいなぁと思っています。

最後までお読みいただきありがとうございました。

Misoca+弥生 Advent Calendar 2019 の4日目は yoshoku さんです。お楽しみに!

*1:なぜかわからない人はマンデルブロ集合の画像をみた後、犬山城の写真を目を細くして見てみよう。ほら、わかるでしょ?