[JavaScript] 筆圧対応ペイントアプリを作る。PointerEvent・手ぶれ補正・差分Undo・ag-psdによるPSD入出力

[JavaScript] 筆圧対応ペイントアプリを作る。PointerEvent・手ぶれ補正・差分Undo・ag-psdによるPSD入出力

はじめに

ワコムのペンタブで描ける、ブラウザ完結のペイントアプリ「Paint Core Studio」を作った。

ビルド不要の素の ES Modules 構成で、外部ライブラリは PSD の読み書きに使う ag-psd だけ。レイヤー、6種類のブラシ、手ぶれ補正、PSD 保存・読み込み、PNG/JPEG/WEBP 書き出し、液タブの ExpressKey を模した浮遊パッドとパイメニューを載せている。

作り込み始めると終わらないので、いったんここで区切って公開する。この記事では、実装してみて分かったペイントアプリ特有の勘所をまとめる。

スクリーンショット

構成

ファイル役割
CanvasEngine.js表示用 canvas、ドキュメント canvas、DOMMatrix による表示変換、描画の間引き
LayerManager.jsレイヤー(1枚=1つのオフスクリーン canvas)、合成、レイヤーパネル UI
BrushEngine.js筆圧、手ぶれ補正、各ブラシの描画、ストロークの開始・終了
HistoryManager.js差分方式の Undo / Redo
keySettings.js浮遊パッドのキー割り当て、設定の保存・入出力
ExpressKeyUI.js / PieMenu.js浮遊パッド、右クリックのパイメニュー
main.js入力イベント、コマンド表、ファイル入出力

ドキュメントは既定で 2048×2048。表示用 canvas(画面サイズ)とは完全に分けていて、描画はすべてドキュメント座標で行う。


1. 筆圧入力:PointerEvent の扱い

マウスもペンも PointerEvent で統一して受け取る。筆圧が意味を持つのは pointerType === 'pen' のときだけなので、それ以外は 1.0 に固定する。

readPressure(e) {
  if (e.pointerType !== 'pen') return 1.0;
  if (typeof e.pressure === 'number' && e.pressure > 0) {
    this.lastPressure = e.pressure;
    return e.pressure;
  }
  // ドライバによっては描画中に 0 が混ざるので、直前の値で埋める
  return this.lastPressure;
}

筆圧は線幅にそのまま掛けず、少し持ち上げるカーブを通している。

w=max⁡(0.5, size×p0.8)w = \max(0.5,\ size \times p^{0.8})

指数を 1 未満にすると、弱い筆圧でも線が細くなりすぎない。

取りこぼしを防ぐ 2 つの API

  • getCoalescedEvents():ペンタブは 200Hz 以上で座標を送ってくるが、pointermove はディスプレイの描画周期にまとめられて届く。まとめられた中間点をこの API で取り出して全部描かないと、速い線がカクカクになる。
  • setPointerCapture():これを呼ばないと、ストローク中にペンがキャンバスの外に出た瞬間に pointermove が届かなくなり、線が途中で止まる。
viewCanvas.addEventListener('pointerdown', (e) => {
  // ...モード判定...
  if (e.button === 0 && brushEngine.beginStroke(e)) mode = 'draw';
  viewCanvas.setPointerCapture(e.pointerId);
});

viewCanvas.addEventListener('pointermove', (e) => {
  if (pointerMode === 'draw') {
    const events = e.getCoalescedEvents ? e.getCoalescedEvents() : [];
    for (const ev of (events.length ? events : [e])) brushEngine.moveStroke(ev);
  }
});

2. 手ぶれ補正:ローパスフィルタとその弱点

手ぶれ補正は、1次のローパスフィルタ(指数移動平均)で実装している。入力点が来るたびに、補正後の位置を生の位置へ一定の割合だけ近づける。

Pn=Pn−1+(Praw−Pn−1)×k,k=max⁡(0.05, 1−0.09×s)P_{n} = P_{n-1} + (P_{raw} - P_{n-1}) \times k,\quad k = \max(0.05,\ 1 - 0.09 \times s)

ss は UI の補正強度(0〜10)。s=0s = 0 なら k=1k = 1 で補正なし、s=10s = 10 なら k=0.1k = 0.1 で、生の位置の 1 割ずつしか追いつかない。

let currentPos = rawPos;
if (this.smoothing > 0) {
  const factor = Math.max(0.05, 1 - this.smoothing * 0.09);
  currentPos = {
    x: this.smoothPos.x + (rawPos.x - this.smoothPos.x) * factor,
    y: this.smoothPos.y + (rawPos.y - this.smoothPos.y) * factor,
  };
  this.smoothPos = currentPos;
}
this.drawSegment(this.lastPos, currentPos, pressure);
this.lastPos = currentPos;

補正後の点どうしは直線(lineTo)でつないでいる。getCoalescedEvents() で点が十分に細かく取れているので、ベジェ補間をしなくても折れ線は目立たない。

払いが短くなる問題

このフィルタは、線を遅らせることで滑らかにしている。補正を強くするほど、描画位置は実際のペン先より後ろを遅れてついてくる。

ペンを離した時点で、その遅れた分は描かれずに終わる。途中で一度、「ペンアップ位置まで直線で描き切る」補完を入れてみた。ところが、払いの先に離す直前の弱い筆圧で細い直線が伸びてしまい、明らかに悪化した。結局、補完はやめている。

ゆっくり筆圧を抜きながら離せば、きれいな払いになる。ただ、速い払いには補正が強いほど弱い。これを本格的に解決するなら、ペンアップ時に筆圧を徐々に抜きながら先端を伸ばす「抜き」の補完が必要になる。今後の課題。


3. ブラシ

ブラシ描き方
Gペン筆圧で幅を変えた lineTo。丸キャップ
毛筆45° 傾けた楕円のスタンプを 1.5px 間隔で打つ。速い区間ではランダムに間引いてかすれを出す
ソフト放射グラデーションの円をスタンプ
水彩ストロークバッファ方式(後述)
鉛筆半径内にランダムな 1.2px の点を低い α で散らす
消しゴムdestination-out で lineTo

長さ 0 の線分(クリックしただけの点)は、環境によっては lineTo で何も描かれない。そのため、点は円で打つようにしている。

function strokeOrDot(ctx, p1, p2, width) {
  ctx.beginPath();
  if (p1.x === p2.x && p1.y === p2.y) {
    ctx.arc(p1.x, p1.y, width / 2, 0, Math.PI * 2);
    ctx.fill();
  } else {
    ctx.moveTo(p1.x, p1.y);
    ctx.lineTo(p2.x, p2.y);
    ctx.stroke();
  }
}

水彩:線分ごとに α を掛けると数珠になる

最初は、半透明の strokeStyle で線分を 1 本ずつ描いていた。すると、隣り合う線分の丸キャップが重なる部分だけ 2 回塗られて濃くなり、線が数珠状になる。

そこで、ストロークの間は別の canvas(ウェットバッファ)に不透明で描いておく。レイヤーのほうは、変更があった矩形だけを「描き始め時点のレイヤー + バッファ × α」で毎回作り直す。

// バッファには不透明で描く(重なっても濃くならない)
wet.lineWidth = this.effectiveSize(pressure);
strokeOrDot(wet, p1, p2, wet.lineWidth);

// 変更範囲だけ、描き始めの状態に戻してからバッファを α 付きで重ねる
this.restoreRegion(r);            // backupCanvas から r をコピー
ctx.globalAlpha = WATERCOLOR_ALPHA;
ctx.drawImage(this.wetCanvas, r.x, r.y, r.w, r.h, r.x, r.y, r.w, r.h);

1 本のストロークの中では濃さが均一になり、別のストロークどうしは重ねた分だけ濃くなる。水彩らしい挙動になる。

「描き始め時点のレイヤー」のコピー(backupCanvas)は、直線・四角・円のプレビュー(毎フレーム元に戻してから描き直す)と、後述の Undo でも使い回している。


4. 表示変換:DOMMatrix でパン・ズーム・回転・反転

表示位置は 1 つの DOMMatrix で持っている。操作は「画面座標系で行列を左から掛ける」だけで書ける。

// カーソル位置を中心にズーム
zoom(deltaY, clientX, clientY) {
  const factor = deltaY < 0 ? 1.1 : 0.9;
  const sx = clientX - rect.left, sy = clientY - rect.top;
  this.transform = new DOMMatrix()
    .translate(sx, sy).scale(factor, factor).translate(-sx, -sy)
    .multiply(this.transform);
}

// 画面中心で回転
rotate(deg) {
  const cx = this.viewCanvas.width / 2, cy = this.viewCanvas.height / 2;
  this.transform = new DOMMatrix()
    .translate(cx, cy).rotate(deg).translate(-cx, -cy)
    .multiply(this.transform);
}

ペンの位置は逆行列でドキュメント座標に戻す。左右反転は表示だけの反転なので、変換の最後に X を折り返す。

screenToDocPos(clientX, clientY) {
  const pt = new DOMPoint(clientX - rect.left, clientY - rect.top)
    .matrixTransform(this.transform.inverse());
  const x = this.isFlippedX ? this.docWidth - pt.x : pt.x;
  return { x, y: pt.y };
}

画面への描画は requestAnimationFrame で間引いている。高レートの入力で 1 フレームに何十回も render() が呼ばれても、合成と転送は 1 回で済む。

ウィンドウをリサイズしたときは、表示をリセットせず、中心のずれだけを平行移動で補正する。ズームと回転はそのまま残る。


5. Undo:全面スナップショットをやめて差分にする

最初の実装は、1 ストロークごとに全レイヤーの getImageData を保存していた。2048² の RGBA は 1 枚 16MB なので、3 レイヤー × 30 段で約 1.4GB になる。これではすぐにタブが落ちる。

いまは、変更の種類ごとにコマンドとして記録している。

種類保存するもの
pixels1 レイヤーの、ストロークが触った矩形だけの before / after
structureレイヤー配列の参照(追加・削除・並べ替え・結合・PSD 読み込み)
prop表示 / 不透明度 / ブレンドの変更前と変更後の値

ストローク中は、描いた範囲を dirty 矩形として広げていく。終了時に、その矩形だけを backupCanvas(before)とレイヤー(after)から切り出す。

commitPixels(layer, beforeSource, rect) {
  const r = clampRect(/* rect をドキュメント内に収める */);
  const before = beforeSource.getContext('2d').getImageData(r.x, r.y, r.w, r.h);
  const after  = layer.ctx.getImageData(r.x, r.y, r.w, r.h);
  this.push({ type: 'pixels', layerId: layer.id, rect: r, before, after, bytes: r.w * r.h * 8 });
}

レイヤー構造の変更は、Layer オブジェクトの配列をコピーして持つだけで済む。そのために、結合では元のレイヤーを書き換えず、新しいレイヤーに合成して差し替えている。削除されたレイヤーも、履歴が参照している限り GC されずに残る。

履歴は 100 手、または推定 512MB を超えたら古い順に捨てる。全消去は after を保存せず、clearRect で再現する(メモリは半分)。


6. ag-psd による PSD 保存・読み込み

ag-psd を使えば、HTMLCanvasElement をそのまま渡して PSD を書き出せる。ただし、いくつか罠がある。

  • 表示状態は visible ではなく hidden。
  • ブレンドモードの名前が Canvas と違う(source-over ↔ normal、color-dodge ↔ color dodge など)。対応表を作って変換する。
  • ルートに合成画像(canvas)を渡さないと、Photoshop 以外のビューアやサムネイルでは真っ白になる。
canvasEngine.renderImmediate(); // docCanvas を最新の合成結果にする

const buffer = agPsd.writePsd({
  width: canvasEngine.docWidth,
  height: canvasEngine.docHeight,
  canvas: canvasEngine.docCanvas,          // 合成画像
  children: layerManager.layers.map(layer => ({
    name: layer.name,
    hidden: !layer.visible,
    opacity: layer.opacity,
    blendMode: canvasToPsdBlend(layer.blendMode),
    left: 0, top: 0,
    canvas: layer.canvas,
  })),
}, { generateThumbnail: true });

読み込みの罠

readPsd が返す各レイヤーの canvas は、レイヤーのバウンディングボックスに切り詰められている。left / top の位置に描かないと、全レイヤーが左上に寄ってしまう。

グループは children がネストした形で来る。このアプリにはグループ機能がないので、再帰的に展開して「グループ名 / レイヤー名」の 1 枚ずつのレイヤーにしている。グループの表示状態と不透明度は子レイヤーに掛け合わせる。

const walk = (nodes, parentVisible, parentOpacity, prefix) => {
  for (const node of nodes) {
    const visible = parentVisible && !node.hidden;
    const opacity = parentOpacity * (node.opacity ?? 1);
    if (node.children) { walk(node.children, visible, opacity, `${prefix}${node.name} / `); continue; }
    if (!node.canvas) continue; // 調整レイヤーなど

    const layer = new Layer(prefix + node.name, psd.width, psd.height);
    layer.visible = visible;
    layer.opacity = opacity;
    layer.blendMode = psdToCanvasBlend(node.blendMode);
    layer.ctx.drawImage(node.canvas, node.left ?? 0, node.top ?? 0);
    layers.push(layer);
  }
};

ドキュメントサイズも PSD の width / height に合わせて変える。読み込みは structure コマンドとして記録するので、読み込み自体を Undo で取り消せる。


7. ショートカットと浮遊パッド:コマンド表に一本化

最初は、パッドのボタンが押されたら KeyboardEvent を偽装して dispatchEvent する設計にしていた。キー割り当ても、それとは別系統で持っていた。結果として、

  • 偽装イベントに code がなく、e.code で判定している側に届かない
  • ボタンに data-id がなく、クリックの委譲が空振りする
  • 保存した設定を実際の処理に反映する経路がない

という状態で、設定画面は実質動いていなかった。イベントを偽装すると結合は疎になるが、何がどこで処理されるのかを追えなくなる。

いまは、すべての入力を 1 つのコマンド表に通している。

キーボード ─┐
            ├─> combo 文字列 ("Ctrl+z") ─> KEY_SHORTCUTS ─> COMMANDS[id].run()
浮遊パッド ─┘   (パッドは「どの combo を送るか」を設定で持つ)
パイメニュー ──────────────────────────────────────> COMMANDS[id].run()
const COMMANDS = {
  undo:         { label: '元に戻す',        repeat: true,  run: () => historyManager.undo() },
  zoomIn:       { label: 'ズームイン',      repeat: true,  run: () => zoomAtCenter(-1) },
  toggleEraser: { label: 'ペン ⇄ 消しゴム', repeat: false, run: () => /* ... */ },
  // ...
};

const KEY_SHORTCUTS = {
  'Ctrl+z': 'undo', 'Ctrl+Shift+z': 'redo', 'Ctrl+y': 'redo',
  'w': 'zoomIn', 's': 'zoomOut', 'e': 'toggleEraser', 'Delete': 'clearLayer', /* ... */
};

combo 文字列の作り方にもコツがある。

  • 英字・数字は e.code から取る。キー配列、IME、Mac の Alt による e.key の変化に影響されない。
  • 記号は e.key から取る。JIS 配列では [ キーの code が BracketRight になるため。
  • Mac の Cmd は Ctrl として扱う。

設定画面には、各ボタンのキーが実際にどのコマンドになるかを表示している。割り当てたキーに機能がなければ、そう表示される。設定は localStorage(使えない環境では保存を諦める)と JSON ファイルで入出力でき、旧形式の設定も読み込み時に変換する。

Space でパンすると、直前に押したボタンが反応する

ヘッダの「戻る」をクリックすると、フォーカスがボタンに残る。その状態で Space(パン)を押すと、ブラウザの標準動作でボタンが押され、Undo が走ってしまう。

対策として、Space の keydown では preventDefault() とフォーカス解除を行う。パッドのボタンは tabindex="-1" にして、mousedown で preventDefault() し、そもそもフォーカスを取らないようにしている。


8. 画像書き出し

PNG / JPEG / WEBP は canvas.toBlob() だけで書き出せる。ドキュメント canvas には合成時に白背景を敷いているので、透過に対応していない JPEG でも透明部分が黒くならない。

canvasEngine.renderImmediate();
canvasEngine.docCanvas.toBlob((blob) => {
  downloadBlob(blob, `art_export_${Date.now()}.${ext}`);
}, mimeType, 0.95);

function downloadBlob(blob, filename) {
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = filename;
  document.body.appendChild(a);
  a.click();
  a.remove();
  setTimeout(() => URL.revokeObjectURL(url), 1000); // 即 revoke すると失敗するブラウザがある
}

実装機能一覧

機能実装
筆圧PointerEvent.pressure、p0.8p^{0.8} のカーブ、0 のときは直前の値で補う
入力の取りこぼし対策getCoalescedEvents()、setPointerCapture()
手ぶれ補正指数移動平均(強度 0〜10)
ブラシGペン / 毛筆 / ソフト / 水彩 / 鉛筆 / 消しゴム、直線・四角・円
表示操作DOMMatrix によるパン・ズーム・回転・左右反転、rAF で描画を間引く
レイヤー追加・削除・並べ替え(D&D)・結合、16 種類のブレンドモード、不透明度
Undo / Redo矩形差分・構造参照・プロパティの 3 種類のコマンド、100 手 / 512MB 上限
PSDag-psd で保存(合成画像・サムネイル付き)と読み込み(オフセット復元・グループ展開)
画像書き出しtoBlob() による PNG / JPEG / WEBP
操作 UI浮遊パッド、パイメニュー、キー割り当ての設定と JSON 入出力

まとめ

  • 筆圧対応で本当に効くのは、入力の取りこぼし対策。getCoalescedEvents() と setPointerCapture() がないと、どんなブラシを作っても線が破綻する。
  • 手ぶれ補正は、遅延と引き換えに滑らかさを得ている。この遅れは、払いの短さとして必ずどこかに現れる。
  • Undo は最初から差分で設計する。全面スナップショットは実装こそ楽だが、2048² のキャンバスではすぐにメモリが足りなくなる。
  • 入力の経路は 1 本にする。イベントの偽装で疎結合にしたつもりが、どこにも届かない入力を量産していた。

抜きの補完、筆圧カーブの調整、筆圧そのもののスムージングなど、書き心地の面ではまだやれることが多い。ただ、ひとまず「描けるもの」として公開する。