Canvas2D RadialGradientのパフォーマンス検証

この記事は [Web グラフィックス Advent Calendar 2022](https://qiita.com/advent-calendar/2022/webgraphics) の1日目の記事です。

# ご挨拶

Webグラフィックス アドベントカレンダーの季節がやってきました！
だんだん参加者数が寂しくなってきていますが、筆者は今年もまた重箱の隅をつつく記事を投稿したいと思います:fist_tone1:

# 発端：自作お絵かきツールでブラシ塗りがしたい

個人でElectronで動作するドロー系の絵かきツールを作っています。データをビットマップで持つのではなく、ベクトルデータで持つことで、後から修正しやすいのが売りのつもりです。
HTML/Canvas2Dで作っているので、Canvas2Dの基本機能だけで描画しようとすると滑らかなグラデーションで複雑な図形を塗りつぶすのは意外と難しかったりします。
やり方は色々あるわけですが、いちばん基本的な方法と思われるのが、パスで囲んだ領域を塗りつぶす方法です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59191/44244644-b775-e215-453c-585c1d4c21bc.png)

ただ、この方法だと基本的にはベタ塗りか線形グラデーション（LinearGradient）か、放射グラデーション（RadialGradient）か、それらの組み合わせで中を塗りつぶすことになります。組み合わせしだいで複雑な塗りもできますが、編集操作が難しくなりがちなんですよね。[過去にも努力はしてみた](https://qiita.com/warotarock/items/b22b6eaffd38e8aaadad#%E8%87%AA%E5%8B%95%E5%A1%97%E3%82%8A%E3%81%A4%E3%81%B6%E3%81%97)のですが、普通のペイント系ツールのようにブラシで塗るのが一番直感的だというのが、自分の中で最近出た結論です。

# ブラシ塗りをどう実現するか

ブラシ塗りをするといってもデータをビットマップで持つと後から修正するのが楽であるというドロー系ツールの利点が薄れてしまいますから、データはベクトルデータで持ちたい。また、ある程度高速に描画できる必要があります。

実現の方法として単純なのは、放射グラデーションを使う方法です。ブラシの点一つひとつを位置と半径と色で表現します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59191/845fafac-9ff1-6e84-68a8-6666cb33d348.png)

他にもビットマップでブラシを実現する方法もあります。ただ、Web環境だと色を変えながら大量に描画する手段が問題です。CanvasRenderingContext2D.filter の hue-rotate() がありますが、まだSafariが対応していないようです。（2022年12月現在）

それで、ひとまず放射グラデーションで作ってみることにしました。

が、本格的に作る前に速度面の検証をしました――というのが今回の記事の趣旨になります。

# パフォーマンス検証・RadialGradientは速いのか

Canvas2D RadialGradient のパフォーマンス検証です。
CodePenでデモにしました。
描画以外の実行時間を無視するために、データの生成と描画を分けてあります。
描画数を多くしたほうが差がわかりやすいと思います。

<p class="codepen" data-height="600" data-default-tab="result" data-slug-hash="ExRdaXZ" data-user="warotarock" style="height: 600px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/warotarock/pen/ExRdaXZ">
  Canvas2D RadialGradientのパフォーマンス検証</a> by 柏崎ワロタロ (<a href="https://codepen.io/warotarock">@warotarock</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

## 円のサイズ5～10で実行

思い切って10万でやってみました。
実行すると、宇宙背景放射みたいなものが出来てきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59191/55ac87d8-e0fd-2c8f-b681-7f6b704a3e52.png)

```
描画: 個数 100000 サイズ 5～10 -> 1124 ms
描画: 個数 100000 サイズ 5～10 -> 1220 ms
描画: 個数 100000 サイズ 5～10 -> 1122 ms
描画: 個数 100000 サイズ 5～10 -> 1158 ms
描画: 個数 100000 サイズ 5～10 -> 1423 ms
```

なお、ブラウザはChrome 107、PCは10年物の自宅PC(Core i7-2600K)で実行しています。
10万回描画で1.1～1.2秒ですね。ちなみにFireFoxだと15秒くらいかかりました。何がそんなに違うのやら。

描画処理は抜粋ですが次のようになっています。

```js
  function createGradient(x, y, radius, color1, color2, ctx) {
    const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius)
    gradient.addColorStop(0.0, color1)
    gradient.addColorStop(1.0, color2)
    return gradient
  }

  function draw(particles, ctx) {
    for (const particle of particles) {
      const gradient = createGradient(
        particle.x,
        particle.y,
        particle.radius,
        particle.color1,
        particle.color2,
        ctx
      )
      ctx.fillStyle = gradient
      ctx.fillRect(
        particle.x - particle.radius,
        particle.y - particle.radius,
        particle.radius * 2,
        particle.radius * 2
      )
    }
  }
```

Canvas2Dでは、放射グラデーションを描画するには座標や半径を指定してRadialGradientを生成し、コンテキストのfillStyleに設定します。

1回の描画ごとにオブジェクトを生成するわけで、10万回も生成して速度に影響はないのか？ と思ったので RadialGradientの生成だけ時間を計測できるようにしました。

```
RadialGradientの生成のみ実行: 個数 100000 サイズ 5～10 -> 279 ms
RadialGradientの生成のみ実行: 個数 100000 サイズ 5～10 -> 312 ms
RadialGradientの生成のみ実行: 個数 100000 サイズ 5～10 -> 296 ms
RadialGradientの生成のみ実行: 個数 100000 サイズ 5～10 -> 342 ms
RadialGradientの生成のみ実行: 個数 100000 サイズ 5～10 -> 290 ms
```

結果を見ると、0.3秒くらいですから、描画時間の4分の1くらいがRadialGradientの生成なんですね。ちょっと、うーんとなりますね。このオブジェクトは生成した後はaddColorStopくらいしかできないので、位置やサイズを変えるには生成するしかないんです。位置くらい変えさせてくれたらいいのに！

実は、座標とサイズを変更する方法はあります。 CanvasRenderingContext2D のsetTransform を使うと変換行列で位置やサイズを調整できるので、それを使います。

```js
   function drawByTransformMatrix(particles, ctx) {
    const gradient = createGradient(
      0.0,
      0.0,
      1.0,
      `rgba(255, 255, 0, 1.0)`,
      `rgba(255, 255, 0, 0.0)`,
      ctx
    )
    ctx.fillStyle = gradient
    for (const particle of particles) {
      ctx.setTransform(
        particle.radius, 0.0,
        0.0, particle.radius,
        particle.x, particle.y
      )
      ctx.fillRect(-1.0, -1.0, 2.0, 2.0)
    }
    ctx.setTransform(
      1.0, 0.0,
      0.0, 1.0,
      0.0, 0.0
    )
  }
```

実行すると・・・

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59191/44ac744c-c095-def7-f0fd-6ddfa8495941.png)

色が変えられないので見た目はアレですが。

```
行列で描画: 個数 100000 サイズ 5～10 -> 551 ms
行列で描画: 個数 100000 サイズ 5～10 -> 547 ms
行列で描画: 個数 100000 サイズ 5～10 -> 547 ms
行列で描画: 個数 100000 サイズ 5～10 -> 559 ms
行列で描画: 個数 100000 サイズ 5～10 -> 561 ms
```

倍くらい速くなった！
やった！

ちなみにFireFoxでは15秒→0.3秒になりました。
50倍速！
やった！

・・・いや、いくらなんでもおかしい(笑)

確かめたところ、RadialGradientの生成はFireFoxのほうが速いくらいだったので、どうやらfillStyleを更新するところが重いようです。これは上記のデモでも確認できます。

## 円のサイズを変えてみる

10～15の場合
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59191/a145e537-4b0c-0a4e-400a-077ecb37db70.png)

15～30の場合

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59191/efed297a-ed3c-16b8-0eab-50c625261ded.png)

30～50の場合

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59191/63cde106-4829-00a3-888d-f37a1b4d810a.png)

（膨張する宇宙って感じ）

```
描画: 個数 100000 サイズ 30～50 -> 1463 ms
データ生成: 個数 100000 サイズ 30～50 -> 164 ms
描画: 個数 100000 サイズ 15～30 -> 1456 ms
データ生成: 個数 100000 サイズ 15～30 -> 161 ms
描画: 個数 100000 サイズ 10～15 -> 1536 ms
データ生成: 個数 100000 サイズ 10～15 -> 162 ms
描画: 個数 100000 サイズ 5～10 -> 1408 ms
データ生成: 個数 100000 サイズ 5～10 -> 210 ms
```

描画面積が増えてもあまり変わらないようですね。前処理や後処理が描画にかかる時間のほとんどを占めているということかもしれません。もしくは何かがものすごくスキップされているのかもしれません。これは結果だけ見ても原因が想像しづらいですね・・・。

# まとめ

- 放射グラデーション（RadialGradient）を10万回描画して検証した
- RadialGradient の生成や fillStyle の更新が意外と重い
- 位置と大きさを変えるだけなら setTransform が速い
- 描画面積が増えても（今回やった程度なら）あまり重くならない

放射グラデーションでもけっこういけそうだなーと（この時点では）思いました。続きがありますので、別の日に投稿する予定です。

それでは、今年もWeb グラフィックス Advent Calendarをよろしくお願いいたします！