はじめに

2010 年くらいからのんびり開発している趣味のお絵かきツール Diceros の実装について、ドキュメントを残しておいたほうがよさそうなのでこの文章を書きます。
まだ未完成ですが、お絵描きツールは以下の URL で開発バージョンを触ることができます。IE10+, Google Chrome 17+ あたりで動作すると思います。Wacom のペンタブレットプラグイン(MouseEvent)、Android Chrome(TouchEvent)、IE10(PointerEvents) あたりの筆圧に対応してます。 

しばらく触るとわかると思うのですが、このお絵描きツールは(VectorLayer/SVGLayerでは)一度書いた線をあとから調整することができるようになっています。

一度描いた線の再描画が多い

Diceros では描いた線を後から編集することができるため、それぞれの線を個別に保持しておかなくてはいけません。
個別の線をそれぞれ専用のオフスクリーンバッファ(DOMツリーから分離されているCanvas)に描画しても良いのですが、バウンディングボックスなどで極力小さくなるようにしても莫大な量のメモリを消費してしまいますし、余白部分が多いので描画自体もあまりはやくありません。
そこで、Diceros では描画命令を記録しておく方法を採用し、再描画処理を高速に行うように工夫しています。

描画命令を保存する

Diceros では高速化のため CanvasRenderingContext2D の一部メソッドと同じインターフェースを持った別のオブジェクトに保存しています。
再描画のたびに描画に(制御点の位置など)必要な情報を計算しなおすのはムダなため、描画命令自体をフックして覚えておこうという作戦です。

また、これは当初想定していなかったのですが、Canvas の他にも SVG で描画するのも中間形式から変換するだけなので簡単に実装することができました。

中間形式のデータはとても簡単になっていて、以下の様な Array で実装されています。

[[メソッドID], [メソッドの引数の数], [引数1], [引数2], ... ]

fillStyle のようなプロパティも Object.defineProperty で setter を設定して、この配列に入るようになっています。

線の編集

線の編集では、編集中の動作を重くしないために以下の三つのオフスクリーンバッファに描画したあと、結合しています。

  • 編集中の線より前の線
  • 編集中の線
  • 編集中の線より後ろの線

diceros_line_layer

ここで重要なのは、頻繁に再描画されるのは「編集中の線」だけで、それ以外は一度描画したらあとはそのまま再利用できる点です。

編集中の前後の線の再描画

編集前後の線は、(線の編集中は)めったに更新されないため "function 生成" というテクニックを使うことで高速化することができます。
なぜ高速になるのか、簡単な例で説明します。

たとえば、上記の中間形式を描画させようとしたら以下のようなコードになると思います。

function draw(ctx) {
    // 描画命令の中間形式配列
    var codeArray = [...];
    // メソッドID から メソッド名への変換テーブル
    var codeToName = [...];
    // CanvasRenderingContext2D のメソッド名
    var method;
    // 引数の個数
    var argnum;

    for (var i = 0, il = codeArray.length; i < il; ++i) {
        // メソッド ID からメソッド名への変更
        method = codeToName[codeArray[i++]];
        // 引数の個数の取得
        argnum = ctx[codeArray[i++]];
        // メソッドの実行
        ctx[method].apply(ctx, codeArray.slice(i, i += argnum));
    }
}

これを function 生成するコードにすると以下のようになります。

function createDrawFunction() {
    // 描画命令の中間形式配列
    var codeArray = [...];
    // メソッドID から メソッド名への変換テーブル
    var codeToName = [...];
    // CanvasRenderingContext2D のメソッド名
    var method;
    // 引数の個数
    var argnum;
    // 生成される function の中身
    var functionBody = [];

    for (var i = 0, il = codeArray.length; i < il; ++i) {
        // メソッド ID からメソッド名への変更
        method = codeToName[codeArray[i++]];
        // 引数の個数の取得
        argnum = ctx[codeArray[i++]];
        // メソッド呼び出しを作成
        functionBody.push(
            "ctx." + method + "(" + codeArray.slice(i, i += argnum).join(',') + ')'
        );
    }

    return new Function("ctx", functionBody.join('\n'));
}

これを実行すると、例えば以下のような function が生成されます。

function(ctx) {
    ctx.beginPath();
    ctx.moveTo(0,0);
    ctx.lineTo(50,50);
    ctx.lineTo(100,100);
    ctx.closePath();
    ctx.fill();
}

このように、中間形式を使わず純粋に描画するためだけの function が作成されます。
この生成された function は配列やループ、変数の参照など、描画命令以外の余計な処理が一切入らないのでかなり高速に動作します。

編集中の線の再描画

編集中の線の再描画は、頻繁に更新されるため、小細工を加える余地がすくないです。
中間形式を用いてそのまま描画しています。
上記の function 生成を利用しないのは、編集中の線は「変更=再描画」となるので function を生成するメリットがないからです。
(むしろ、function を生成するコスト分だけ損になります。)

その他

紀平さんが Chrome+HTML5 Developers Live Japan #5 で function 生成についてライブコーディングしながら説明しているので、より丁寧な説明が必要な方は参考になると思います。