10
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Electron+Canvas+WebGL+Reactのお絵描きツールの進捗2023

Last updated at Posted at 2023-12-11

この記事は グラフィックス全般 Advent Calendar 2023 11日目の記事です。

こんにちわ。筆者は趣味と実益(現状ゼロ)を兼ねてイラスト制作・お絵描きツールを作っています。

アプリ外観

もう7年目くらいです。昨年の記事はこちら。よく続いたねホント。

ツールの特徴はこんな感じです。

  • ウェブ技術を基盤に実装(Electronまたはブラウザ、キャンバスAPI+WebGL+React)
  • ブラウザではIndexedDBにデータ保存
  • ベクター方式で描いた後から楽に修正できるのがウリ

見えてきた二つの道

この2年くらいの個人的テーマは「自分以外でも使えるようにする」です。
その背景には、自己満足で終わりたくないというのと、せっかくだし収益化したいという思いがあるんですが、どうもこのツールは自分しか使えなさそうだと思っていました。

自分しか使えないツールになっている理由は色々あるんですが、一番はたぶん表現力の問題だと思います。
去年の時点では描画処理は全てキャンバスAPIを使っていて、キャンバスAPIのソリッドな線しか描けないので、絵柄の幅は狭かったです。できるだけ大きく描いて線を細くして使ってました。

キャンバスAPIのソリッドな線
(キャンバスAPIのソリッドな線)

それで以前から色々実験もしていて、WebGLを使えばかなりのことができそうだなーということは分かっていました。

ここで二つの選択肢が見えてきました。

  • メモ用のツールとして進歩させていく(図形描画機能などを充実させていく)
  • イラスト制作用のツールとして進歩させていく(表現力を充実させていく)

たぶんメモ用のツールにしたほうが作るのは楽ですし、対象ユーザーも多い気がします。
ただ、同じようなウェブアプリは既にありますから(Excalidraw や draw.io は有名)作る意味が薄いような気がします。それに自分がもともと作りたかったものとは違うはずでした。最初は3Dデッサン人形を使って簡単に人が描けるツールとして作り始めたので、絵を描くツールが欲しかったはずなんですね。

結論が出た後で記事を書いているので、今見ると「なんだ結論は出てるじゃないか」と思いますが、当時はそうとう悩みました。なにしろ実装が半端じゃなく大変なことは薄々感づいていたんです。でも、最終的に決めてになったのは自分(もしくは自分と同じ嗜好の人)が欲しいかどうかだったような気がします。

線画とブラシをWebGLで実装する

てなわけで、やっぱり自分好みのお絵かきツールが欲しくて作ってきたんだから初志貫徹するぞ思い切ってWebGL実装に踏み切りました。

まずは線画レイヤーをWebGLで実装しなおすことを考えます。

猫1 WebGL実装の開始
(考えているときの筆者)

…死にたい(笑)

プログラマーと話していて相手がとつぜん目が虚ろになって黙り込んでしまったら、頭の中が上図のようなっている場合があります。彼らはそのとき別の宇宙にいます。職業病なので許してやってください。

コマンドとかフラグの話は書いても面白くないのでやめときますが、実装について簡単に紹介すると、シェーダは "cubic bezier glsl" とかで検索すると出てくるものを参考にしました。計算の途中で曲線内の正規化された位置の値が出るので、線幅を変えたりするのは簡単です。あと、メッシュは地道に構築しています。直線を内包するようにポリゴンを組んで、頂点にはその頂点が属するセグメントのベジエ曲線のパラメータを、セグメント内の頂点は全て同じものを与えています。

これで線の途中で太さを変えたり、ぼかしを入れたり、テクスチャを使えるようになりました。

WebGL実装の成果

ブラシ塗りレイヤーで「はみ出し防止」と「隙間の許容」を実装する

キャンバスで図形に色を塗るというと、パスで囲んだ中をフィルする方法があります。が、これをそのままツールにすると、お絵かき的な用途としてはあまり使いやすいとはいえません。そこで昨年、「ブラシ塗り」を実装しました。ブラシ点を中心に円形に色を塗るやりかたです。

このツールにおけるブラシ塗りの特徴として、はみ出し防止機能があります。線画レイヤーの線を越えないで色を塗ることができます。これをラスター的にやるのではなくベクター的にやっています。

はみ出し防止

アルゴリズム的には、ブラシ点を中心として全ての角度にレイを飛ばし、線画にぶつかるところまでの距離を値とする遮蔽マップとして記録しておき、遮蔽マップをもとにブラシの及ぶ範囲のマスクを生成する、というやり方です。

ブラシ塗りアルゴリズム

ただ、昨年の時点では小さな隙間を許容することができなくて、線画をきちんと全部閉じておく必要がありました。

それだとやはり不便なので、隙間を許容する処理を作ることにしました。

どんなアルゴリズムになるのかというと――

猫2 隙間の許容

…死にたい(笑)

複数の線が絡んでくるとえらい複雑。うまくできたと思って実際に使ってみるとすぐに対応できてないケースが出てきますし。

広がる無限の可能性.png

できあがったアルゴリズムはものすごく大雑把にいうと、時計回りに線を追いかけるように走査していき、線に追いつけなかったら隙間とみなし一定の大きさ以下なら補間した距離を遮蔽マップに上書きする、同じように反時計回りでもやって、できた遮蔽マップを角度ごとに距離が小さい方を採用してマージする、という感じです。

上の図を見るとわかるようにところどころ怪しいんですが、そもそもどうなるのが正しいのか答えが無いのが問題かもしれません。究極的には流体シミュレーションをすれば自然法則にのっとって妥当な塗り方になるんじゃないかと思っています。ハードル高すぎて手が出ないのですが。

なんにせよ、一応は実用になったと思います。7年越しの課題に一つの答えが出たのでホント良かったです。

遅延更新、遅延描画、更新伝搬、描画キャッシュ…地獄の高速化バトル

上記の「はみ出し防止」機能は、線画が変更されると自動で更新されるのが売りです。しかし、マスク生成の処理が重いので、そのままでは線画を少し修正するたびに更新処理が走り、非力な端末ではかなり操作がもたつきます。

これを回避するために、マスクの更新を数秒ほど遅延して行うことにしました。非アクティブなレイヤーの再描画も遅延で行い、さらに描画結果をキャッシュすることにしました。なんと、今までは全部のレイヤーをフル描画していたんですね。よく動いてたね今まで。

また、このツールでは色をパレットで管理していて、パレットの色を変更すると関連する全レイヤーに反映されます。同様に線画スタイルも関連する全レイヤーに反映されます。これらも遅延更新、遅延描画の対象になります。

ということは――

猫3 遅延更新とか色々

…死にたい(笑)

生粋のプログラマーはですね、ボーっと何も考えていないように見えますけど考えてるんです。考えてるから固まるんです。ええ。考えてるときの顔とか間が不安になるって言われますけど思考に全振りしてるからそうなるんですね。宇宙猫なんです。(個人の意見です)

Reactコンポーネントをメモ化した

逆に何があったか聞いたほうがいい

うそやろ React 信じてたのに・・・メモ化ってなんなん?・・・自分が見た入門サイトそんなこと一言も触れてなかったやん・・・Reactの全コンポーネント修正不可避やん・・・

しかも useCallback なにこれ依存する変数全部書くんか・・・パッと見全然わからん・・・

…死んだ(根性で直した)

参考:5歳娘「パパのReact、めっちゃ遅いね!」

線画ツールで筆圧のかわりに線幅を自動で付けるようにした

筆圧は端末によって使えない場合があるので、単純なグラデーションで線幅を付けて線を描ける機能を作りました。

自動線幅

久しぶりに簡単に成果が出たので癒やされました。

カンターン

やはり毛先はシュッとしてると映えますね。

自動線幅機能の成果

夏休みの姪が家に来て、一つのレイヤーで複数の色を使えるようにした(語弊)

姪(9歳)「おじちゃーん!部屋に引きこもってなにしてんの!?」

ワイ「仕事してんやよ」

姪「そうなんだー!お絵かきしたいからPC使わせて!」

ワイ「いや仕事してんのやけど」

姪「どけ!(蹴り)」

ワイ「ウゴ!なにすんねん…ま、いいか。ちょうど休憩しよ思ってたし」
ワイ「せや、どうせならおじちゃんが作ったこのツール使ってみてくれん?」

姪「おじちゃんが作ったの?すごいね!」
姪「グリグリー」

ワイ「へー、器用にペンタブ使いこなすやん。たいしたもんやな」

姪「おじちゃん、これ、どうやって色変えるの?」

ワイ「それはね、ここのパレットを使うんよ」

姪「あれ?色を変えると今まで描いたところも全部色が変わっちゃうよ」

ワイ「それな、レイヤーが色を持ってるからなんよ。後からいくらでも色を調整できるんや。便利やろ」
ワイ「別の色で描きたかったらレイヤーを足すんやで」

姪「へー、めっちゃ分かりづらいね!全部黒でいいや!グリグリー」

ワイ「(分かりづらいのか…?)」

(注:筆者は東北人です。また、この部分は上の参考文献の作者のやめ太郎氏の文体をパクって書いています)

たしかに、普通の感覚では次々と色を変えて重ねて描いていくのが普通かもしれないですね。だいたいののペイントソフトはそうですし。ということで、一つのレイヤーで複数の色を使えるようにすることにしました。

さて実装は――

猫4 レイヤーで複数の色

…死にたい(笑)ありがとう姪っ子。

ウェブで公開することを決断した

9月くらいまではAndroidとiOSでアプリにしようと思っていたんですが、あまりに完成が遠いのでウェブで公開することにしました。ウェブならアフィリエイトで収入にもなるかもしれませんしね。(雀の涙だろうけど)

ブラウザのストレージで完結できるようにした

ユーザーのデータを保存するサーバを運用するのは大変なので、データをIndexedDBに保存してブラウザで完結させることにしました、ただ、その時点では IndexedDB を使えば何でも保存できるらしいくらいのことしか知りませんでした。

が、Dexie を使ったら魔法のようにいとも簡単にできました。

動けば官軍.jpg

ただ注意すべき点として、IndexedDBは列を選択してレコードを取り出せないらしいので、メタ情報用のテーブルとファイル本体用のテーブルを分けることにしました。一覧表示のとき、いったんメタ情報だけ取り出したいからです。

DBの定義はこんな感じにしました。

interface DocumentFileHeaderRow {

  id?: number
  createDate?: number
  modifiedDate?: number
  fileName?: string
  sectionID?: string
}

interface DocumentFileDataRow {

  id?: number
  file?: Blob
}

class DocumentFileDB extends Dexie {
  
  public fileHeaders: Table<DocumentFileHeaderRow, number>
  public fileDatas: Table<DocumentFileDataRow, number>

  public constructor() {

    super('DocumentFile')
    
    this.version(1).stores({
      fileHeaders: '++id, createDate, modifiedDate, sectionID, fileName, [sectionID+fileName]',
      fileDatas: 'id, file'
    })
  }
}

スマホ解像度対応

逆に何があったか聞いたほうがいい

アドレスバーが消せたり消せなかったり、スワイプするたびに出たり引っ込んだり、全画面できたりできなかったり、OSとかブラウザごとになんでこんなに違うのか…セーフエリアってなんやねん存在自体が危険やん…

対応の方針としては、端末のメーカーが適切に1emのスケールを設定してくれていると信じて、emを基本単位にしてCSSを設定することにしました。

こうするとユーザーがブラウザの設定で文字サイズを変えることでUIのサイズを自由に調整できます。今回はしていませんが、アプリから変更するのもhtmlもしくはbodyのfont-sizeを変えるだけで全体に反映されるので簡単です。解像度対応はこれでいいことにしました。

ただ、emは親子要素間の相対値なので変につけるとややこしくなるので、font-sizeをemで指定するときは最下層の要素にだけ設定するようにしています。

ファイラのサムネイル表示

ファイラのサムネイル表示.jpg

ファイラは今までは文字だけだったので使いづらすぎて、せっかく作ったのに使わないでエクスプローラからドラッグドロップしてました。

サムネイル画像はドキュメントファイルに保存してあるので、ドキュメントファイルから読み込んで表示し、Dexie でIndexedDBに保存してキャッシュしています。また、 IntersectionObserver で画面に表示されている項目だけ読み込まれるようにしました。ヒュー!技術の進歩ってスゲー!

カンターン
(他の修正と比べればですけど)

なお、IndexedDB と画面表示のやり取りで多少ハマりました。最終的にできた流れは、まずIndexedDBには画像をbase64化して保存しておき(Blobで保存するとiOS 15.3 Safariで動かない)、表示するときは IndexedDB → base64文字列 → fetch(base64文字列) → result.blob() → URL.createObjectURL(blob) → オブジェクトURL → imgのsrcという感じになりました。面倒くさすぎます。技術はもっと進歩するべきだと思います(すぐ手のひらを返す)

その他、色々と機能を追加

  • ドキュメントファイルをダウンロードできるようにした(データ移行用)
  • アンチエイリアシングを実装(4点マルチサンプリング)
  • 線画ツールで線をどんどん継ぎ足していけるモードを追加
  • 線画ツールで線幅を書き足したり上書きできるモードを追加
  • ブラシ塗りレイヤーで消込を実装(アルファ値をマイナスするブラシ)
  • 消しゴムで線幅とぼかしを削るモードを追加
  • 消しゴムで線が交差しているところまで消すモードを追加
  • 線の修正ツールで押し出しモードを追加
  • 線の修正ツールに延長/結合ツールを統合
  • 初期パレットの内容を色鉛筆などを参考に充実させた
  • タッチ操作によるピンチズームを実装

アンチエイリアシングは座標から一意に濃度を計算する関数が実装できれば、あとは4点の平均をとるだけで簡単に実装できました。簡単なのに効果が大きくて感動モノでした。

公開をいったん断念した機能

  • 囲み塗り
  • 自動囲み塗り
  • 3Dデッサン人形
  • 顔の左右対称自動生成機能

せっかく作ったけど…

逆に何があったか聞いたほうがいい

サイトを作ってアフィリエイトを導入した

公式サイト

11月、公式サイトをオープンしました。でも自信が無いのでほとんどリンクを貼ってません。当然誰も来ない(笑)

サイトの構築は Astro で静的サイト生成してます。

最初はAstroなら React が使えるというので触ってみたんですが、.astro ファイルがほとんどReactみたいな内容なので結局 React は使いませんでした。ただ markdown で簡易ブログを作るところでハマったので、もっと作り込むなら React で作り直すかもしれません。

そして、先日の記事の通りアフィリエイトを導入(?)しています。

保存を5回すると全画面で広告を出すようにしました。このやりかたが吉と出るか凶と出るか、今はまだ全く全然わかりません。

あと4回保存すると

なんとなく思うに、このツールとアフィリエイトの相性はそんなに良くはなさそうです。自分なら何か作業しているときに広告が目に入っても見ないですね。可及的速やかに閉じてます。やっぱりアフィリエイトで一番いいのは直接の商品レビュー記事なのでしょう。

でも、売れそうな商品やオススメしたい商品を探すのは結構楽しいです。

今後

夢のあるところでは次の3つができたらいいなぁと思っています。

  • もっとリアルなスタイル(鉛筆、水彩など)
  • 3Dデッサン人形(性別、体型のカスタマイズ可能)
  • マンガ制作用の機能(文字入れ、トーン)

どれも結構むつかしそうですなぁ。

文字入れは、つい最近宣伝のためAIにデザインしてもらったアドベントカレンダー子を自分でイラストにしたとき、早急に欲しいと思いました。

アドカレ子
(これ全部マウスで描いたんですけど何気にすごくないですか。カンターンも)

あと地味なところではパフォーマンスを改善して線画や消しゴムをリアルタイムにプレビューできるようにしたいというのもあります。

おわりに

長々とお付き合いいただきありがとうございました。
好き勝手やってるだけの身ですが、年に一度の発表の場があるのは励みになります。
いつも参加しておられる方々もお変わり無さそうでなにりよりです。
また、今年のアドベントカレンダーは昨年より賑やかになってきたようで嬉しいです。

公開したサイト、ツールはこれから徐々に広めていきたいと思っております。
もし続いていたら、来年またお会いしましょう。

それでは良いお年を。

10
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?