Electron＋Canvas＋WebGL＋Reactのお絵描きツールの進捗2024

この記事は [グラフィックス全般 Advent Calendar 2024](https://qiita.com/advent-calendar/2024/graphics) 19日目（投稿は25日）の記事です。

どうも。個人で趣味で[Electron＆Webなお絵かきアプリ](https://warotarokz.sakura.ne.jp/illustmate/)を作っている者です。（Electron版は未公開）

昨年の記事で「７年目くらい」と書いていたので、今は８年目ということになるようです。最初と思われる[簡易ポージングツールの試作](https://qiita.com/warotarock/items/f082cca9df99abb50e50#%E7%B0%A1%E6%98%93%E3%83%9D%E3%83%BC%E3%82%B8%E3%83%B3%E3%82%B0%E3%83%84%E3%83%BC%E3%83%AB%E3%81%AE%E8%A9%A6%E4%BD%9C)という記事から数えて８年目です。どうりで爺さんになるわけだ。

# こんなアプリです

![アプリ外観](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59191/778c0b11-30e1-2b9d-e4d7-90cd12d6be21.png)


コンセプトは「お絵描きの面倒くさいを簡単に」です。

といっても今年に入ってから後付けで考えたコンセプトだったりします。従来のペイントソフトやアプリなんかで難しいとか面倒くさかった部分を簡単＆楽にできるように特化していきたいと思っています。

たとえば

- 絵のうまいヘタ以前に一本の線すらきれいに描けねえ…  
　→ペン入力に特化したベクター方式採用で描いた線を後から簡単に修正できる
- 線を直したら塗りも直さないといけない…手戻りは絶許！  
　→塗った部分が線の修正にあわせて自動で更新される
- 色を微妙に調整したいがパーツ分けしたレイヤーが多くてストレスマッハ
パレットで色を管理しているのでレイヤー横断で色を調整できる

というような工夫を詰め込んでいこうと取り組んでいます。

# ２０２４年のハイライト

## レイヤー合成（コンポジション）の実装

いや８年も作っててまだ無かったんかい！と思わず自分で突っ込むくらい、たいていのお絵描きアプリは当たり前に備えている機能ですね。なぜ無かったのか謎です。たぶん難しかったんだと思います。前年にWebGLで線画を描画できるようになったので、その流れで実装することにしました。

レイヤー合成ができるようになったことで、できることが格段に増えました。絵の表現の幅が広がることも当然ながら、後で出てくる消込ツールの実装やビュー操作時の負荷低減でも利用しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59191/8a202b73-53d2-4c34-f34e-2a013e3f2c8a.png)


実装面でいうと、このアプリではレイヤーの入れ子構造をもとに描画命令のリストを作るのですが、そこの修正がかなり難しかったです。自分は手遅れでしたが、これからお絵描きツールを作る人は開発の早い段階からレイヤー合成を考慮してシステムを設計するのをおすすめします。後からやると大変です。

なかなか苦労したので、得られた知見（と苦しみ）を分かち合うため、以下にメモを残します。文字ばっかでごめんなさい。でも経験上、誰かが書き残したこういう記事がヒントになることも多かったです。

レイヤーとグループ関連

- 合成処理を行う際、上下レイヤーの描画結果と合成結果の三つのバッファが必要になる。ダブルバッファリングの考え方を導入すれば、バッファの数を節約できる。
- 合成モードの多くはWebGLのblendFuncだけでは実現できないのでシェーダを実装する必要がある。最初からシェーダを使う前提で設計したほうがよい。（過去の自分に言いたい）
- グループレイヤーは子レイヤーを合成した結果をバッファに描画してキャッシュしておく。合成モードが「通常」以外の子レイヤーが一つであればバッファが必要になる。ビュー操作の高速化のためにも必要になる。
- データに変更があった場合、レイヤーの入れ子構造を一番上の階層までたどってキャッシュを再描画する必要がある。
- 合成モードが通常以外の場合、合成処理の対象にはグループ内でそのレイヤーより下のすべてのレイヤーが含まれる。  
筆者の場合は合成処理の対象となる範囲を仮想的なグループとして扱うようにした。
- 上記と同様に、クリッピングマスク（下のレイヤーのアルファ値でマスクする機能。source-atop処理））のレイヤーが連続している場合、連続している部分を仮想的なグループとして扱うとよい。  
なお、途中のレイヤーの合成モードはそのグループの中でのみ影響し、グループ自体の合成モードは一番下のレイヤーのものになる。一番下のレイヤーの合成モードが通常以外の場合はグループの一番下まで仮想グループとなる。

シェーダ関連

- シェーダで行う計算は[W3C](https://www.w3.org/TR/compositing-1/#generalformula)で書かれている通りで問題ない。方々探してたけどそこの10章の最後に書かれている αs x (1 - αb) x Cs + αs x αb x B(Cb, Cs) + (1 - αs) x αb x Cb という式が本当に筆者が欲しかったものだった。アルファは単純に αb  * (1.0 - αs) + αs となる。  
ただし、ストレートアルファ（RGBにアルファが乗算されていない状態のこと）であることが前提なので、バッファのテクスチャが乗算済みアルファである場合はRGB成分をアルファで割り戻してから計算し最後にまた乗算する必要がある。
- クリッピングマスクが有効な場合でも、色の計算（上の式でB(Cb, Cs)の部分）はクリッピングマスクしないときと同じでよい。全体の計算式としては上の式からαs x (1 - αb) x Csの部分を無くした形になる。
- ユーザー入力中のリアルタイムなプレビューにもコンポジションを反映したい場合、編集中のレイヤーのキャッシュの上にプレビュー用のストロークを描画した後で合成処理をすることになる。  
筆者の場合はレイヤーにプレビュー用の追加情報を持たせられるようにし、ユーザー入力時に追加情報をセットして描画後にクリアするようにした。

## カラーピッカーの拡充

![png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59191/4779736e-12ea-4e0a-4c55-ba33fe563626.png)


それまでHSL風のグリッドだけだったカラーピッカーを、HSVホイール、HSLグリッド、中間色、簡易光学モデルの４つに刷新しました。最初の二つは定番で説明不要と思いますので、後の二つをご紹介します。

![img20240401_003.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59191/337afd24-2b14-f25e-eab4-5f6c62553f31.png)
「中間色」カラーピッカー

オリジナルのカラーピッカーです。夕焼け空の青→赤の色変化とか、紅葉の緑→赤の色変化のようにいったん灰色の状態を経由するような色変化を拾いやすくするのを目的として用意しました。
実はHSV円をガウスぼかししただけのものなので、基本はHSV円と変わりません。違うのは、中心付近の彩度が低い部分の色変化が滑らかなことです。それがどれだけ使いやすいかというと評価が難しいですが、個人的にはわりと良い気がしています。見た目もカラーバンドが無いほうが雰囲気使いやすそうですし。

![png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59191/2c0a233f-b59e-1ed2-ffa5-5cb1f205795a.png)
「簡易光学モデル」カラーピッカー

その名の通り、簡易な光学的なシミュレーションを利用したカラーピッカーです。

光は次の４つがあり、それぞれ色を設定できます。光の強度は色のRGB成分がそのまま光の強度になります。

- メインライト光
- リムライト光
- 床面の素材色
- 球の素材色

たいていの絵は同じ絵の中でも複数の色の素材があるので、球の素材色はたくさん作れるようにしてあります。たとえばメインライトを夕焼け空の色にして球の素材色を肌の色にしたり服の色にしたりすれば、光学的な知識がなくとも光を意識した色作りが簡単にできできるはずです。

実装は、Blenderで各種光の強度をRGBにパックした画像を生成しておき、それぞれの強度と色を掛けたものを加算合成しているだけです。ライトごとに直接光と間接光とスペキュラがあるのでチャネル数は多いですが、計算自体は簡単です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59191/497af10f-ba7d-d2d2-10f2-a571ae0ea2e7.png)

それにしてもレイヤー合成と比べたら詰まりどころが少なくて楽でしたね。
こんなんばっかりならいいのになぁ。

## 「消込」の強化

![png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59191/402e20de-4ad1-a5bc-ad9a-1ec4ecaff22f.png)

「消込」機能はアルファをマイナスするブラシで描画する機能です。上の絵では空色に穴をあけて雲っぽくしている個所で使っています。

以前サイトのトップ絵として雪割草を描いたとき、この機能が思いのほか便利で、最終的にほとんどの塗りのレイヤーで使っていることに気づきました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59191/314c572e-a5b3-e800-7809-0eaa5b6bf7a7.png)
消込の例。右の画像で灰色の部分が消込。

デジタルでお絵描きするときけっこう問題なのが、基本的にブラシの形状が円形で、それだけだと思い通りの形には塗りづらいということです。もちろんブラシの形状も色々ありますし筆圧や色んな機能でコントロールもできるのですが、それよりは、いったん描いてから消しゴムで消すか、クリッピングマスクなどで色が残る領域を決めるほうが多くの場合は確実で簡単です。筆者の場合はそれを「消込」でやっていたわけです。

思うに、マウスやペンのようなポインティングデバイスを前提にしている限りはどう工夫しても一発では思うように描けなくて、それは現状のデジタルお絵かきの宿命みたいなものかなーという感じがします。

そんな体験もあって、消込機能を強化することにしました。今までブラシ塗りレイヤーでしか消込ツールが使えなかったのを、相互に組み合わせられるようにしました。これはレイヤー合成を実装したことで可能になりました。線画レイヤーでブラシ塗りレイヤーを消し込みするとか、その逆とか。さらに、新規実装した囲み塗りレイヤーによる消し込みもできるようになって、より複雑な表現ができるようになりました。こいつは便利ですよ。

![png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59191/27088519-0de9-b683-e300-8d2ac0251bc3.png)
消込と囲み塗りをふんだんに使った例。見た目ではよくわからん…

実装方法としては、消込ツールを使用すると自動的にグループレイヤーを追加してレイヤー合成でアルファをマイナスするように設定が組まれるようにしています。ですから、やろうと思えば消込ツールを使わなくても手動でレイヤーを組めば同じことが可能です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59191/8f7762ee-4fe6-8b7b-4c8a-20f632780aaa.png)

こう説明すると簡単そうですが、実装はしんどかったです。このアプリは自分で思っている以上にデータ/ツール/UIが複雑に連動していて、何か直すたびに理不尽なバグが出てくるわ出てくるわ。もう何か月もデバッグの合間に実装しているという感じでした。

でもまあ、諦めなければなんとかなるもんです。3カ月ほどかかりましたが、なんとかリリースしました。囲み塗りレイヤーなど新機能もありますし、きつかった分だけ達成感がありました。

# ストロークのエディタ表示の改善

下のように、ストロークの入力が最終ルックでリアルタイムにエディタ上で表示されるようになりました。なお新版はWebGL、旧版はHTMLのCanvasによる描画です。

新
![Animation2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59191/5c94d85c-0c2e-c716-0b67-658c5b932510.gif)

旧
![Animation1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59191/6bcc5f31-fe9f-3f9d-b125-d5e110e207d1.gif)

これもとっくにできていて当然の機能なんですけど、やっと手を付けました。ストロークの描画自体はレイヤーの描画ですでにやっているので新しい要素はりません。ただWebGLなので、リアルタイムにストロークのメッシュを構築して再描画する必要があります。パフォーマンスがどこまで出せるかが勝負でした。

でも、今まではCanvasで描画していたのでWebGLならむしろ高速になったりしないかなーと甘い期待もありました。まあでも、正直やってみなければわかりません。

というわけで覚悟をきめて実装しました！（１カ月かかった！）

ひとまず動くようになりました。ただ、２年前に買った無駄にCPUだけ盛ったPCで動かしているとパフォーマンスがどうなのか分かりづらいので、こういう時のために残してあった古いAndroidタブレットで動かしてみました。

結果――

前よりめたくそ重くなった（涙

まあ途中からそんな予感はしてたんですけど。

ちなみに、AndroidのChromeはどうもJavascriptの処理が一定より重くなるとRequestAnimationFrameの実行間隔を極端に遅らせる処理が入っているような感じもありますね（未検証）。やっかいな…

パフォーマンスを上げるには、なにはともあれボトルネックを特定しようということで、WindowsのChromeのデベロッパーツールのパフォーマンス計測で調査しました。

すると、SVGの描画がけっこう時間がかかるらしいことが分かりました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59191/c3d5d393-8d63-353d-343f-0a3f9a9a6c6c.png)

これしか面積ないのに全体の５分の１くらい時間を使っているらしいです。目盛やドキュメント枠もCanvasで描画してますがそちらはそれほどでもありませんでした。

例のAndroidタブレットだとSVGの描画のあるなしで別物のようにフレームレートが変化します。SVGは面積のわりに複雑ですしCPUで描いているんでしょうから、CPUが非力なデバイスだと影響が大きいんでしょうね。GPUのボトルネックを探していたのに、とんでもないCPUのボトルネックを見つけてしまった…どーしよー

幸い、現状の仕様ではストロークの入力中にSVGを再描画する必要が無いことに気づき、再描画を止めることでSVGの問題は解決しました。

そして本題のWebGL側のパフォーマンス改善ですが、これはあまり新しい要素はなくて、差分更新、差分描画、スクリーンキャッシュといったよくある方法を地道に実装しました。

個人的に新しいこととしては、ブラシ塗りレイヤーのはみ出し防止機能をGPU実装しました。今まではCPUでマスク処理をしていました（詳しくは[こちら](https://qiita.com/warotarock/items/971ea5519722becea46d)）。今度はブラシ点ごとに線をはみ出さないための遮蔽マップをもたせ、それをWebGLのテクスチャに転送することでマスク処理無しで描画ができるようになりました。このことで差分描画ができるようになり、同じ線の反対側を違う色で塗ることもできるようになりました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59191/f6f8dfc3-b264-37ea-38b7-4319e76ec960.png)
遮蔽マップのテクスチャを可視化した例


長くなったのでまとめます。

- ストロークのエディタ表示をGPUで実装したら重くなった
- GPUの描画を最適化する前にSVGの描画が重すぎた
- GPU描画を差分描画できるようにして高速化した
- ブラシ塗りレイヤーをGPU実装して、ちょっと便利になった
- ４カ月かかった（本業が忙しくなったせいもあります

これで昨年のアドベントカレンダーでやりたいと言っていたリアルタイムなプレビューはひとまず完成しました。あーよかった。

# ＡＩ関係

グラフィックと直接関係ない話題ですが、今年に入ってから、ＡＩを意識したコーディングをすることにしました。近い将来、ＡＩによる恩恵をできるだけ受けられるように、プログラムのコメントを徹底して書くようにしています。

コメントは日本語で書いています。ＡＩは英語のほうが効率が良いらしいですが、筆者には日本語のほうが良いので日本語で書いています。日本語でコメントを書いていくとＡＩの方もコメントの日本語と英語の対応を憶えてくれるようで、体感として精度はそれほど悪くないように感じます。「囲み塗り」のようなアプリ独自の用語にも対応してくれている感じがします。本当にＡＩが何を考えているのか確証はないですけども。

コメントの内容は対象の処理の内容を書くだけではなくて、実現したいことや他のコードとの関係なども、多少長くなっても書きます。これはプログラムを読んだだけでは推測しづらいこともＡＩに理解して欲しいからです。アプリのプログラムが大きすぎて頭の中に収まりきらなくなってきているので、もっとＡＩに一緒に考えてもらえるようにできたらいいな。うまくいくかは分かりませんが。  
ただ、これは筆者が自分一人で好きにコーディングできる状況だからで、会社などチームで開発をするときにはあまり冗長なコメントは良くないかもしれません。

ちなみにエディタで使っているのはメインがGitHub Copilotで、複雑でないコーディングにはWindSurfも使います。CursorとかWindSurfは自分にはちょっと頑張ってくれすぎに感じます。Tabを押しすぎて「お、落ち着けWindSurf！いや俺も落ち着け！」みたいな感じになります（伝わるかなー）。ほんとに、ＡＩ関連は進歩が速いので困ってしまいますが、楽しみでもあります。

# 今後

基本機能は今年の一年でかなり入りました。ただ、線やブラシのスタイルのクオリティ向上を今年やりたかったのにできなかったので、ひとまずはそれをやりたいと思います。あとは文字入れができたら便利でしょうねー。でもどうやって作ってるんだろう？

夢のあるところでは３Ｄデッサン人形機能とアニメーション機能があります。両方とも、過去にいったん作って捨てた機能です。今だと、コンセプトに合ってるのは３Ｄの方でしょうか。思い入れもあるし、できたらいいですね。

# おわりに

ここまでお付き合いいただきありがとうございました。
年に一度の機会に間に合って（間に合ってない）良かったです。

本当は11日目の予定だったんですが、他の記事にこだわりすぎてケジューリングをミスって最終日の投稿になってしまいました。幸いカレンダーの隙間がまだあったので、できるだけカレンダーの隙間が少なく見えそうだった19日を選んで登録した次第です。

ところで、筆者は個人的にアドベントカレンダーをちょっとした一年の目標にしています。一年の始めに今年は何をしようかと考えて一年の計画を立て、実行し、11月に入るとその年を振り返りつつ記事を書く準備をします。準備の中で、その年に書いたコードを見直して他人に見せられるように調整します。コードを見直す作業は自己点検であり、改善活動にもなります。最近気づいたのですが、これって、いわゆるＰＤＣＡサイクルになってるじゃないですか。Plan、Do、Check、そしてAdventCalendar。いや半分本気ですよ(笑)

今年も残りわずかですね。
カレンダーが来年も続いていたら、またこの時期にお会いしましょう。

それでは良いお年を。