[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;
}
筆圧は線幅にそのまま掛けず、少し持ち上げるカーブを通している。
指数を 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次のローパスフィルタ(指数移動平均)で実装している。入力点が来るたびに、補正後の位置を生の位置へ一定の割合だけ近づける。
は UI の補正強度(0〜10)。 なら で補正なし、 なら で、生の位置の 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 になる。これではすぐにタブが落ちる。
いまは、変更の種類ごとにコマンドとして記録している。
| 種類 | 保存するもの |
|---|---|
pixels | 1 レイヤーの、ストロークが触った矩形だけの 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、 のカーブ、0 のときは直前の値で補う |
| 入力の取りこぼし対策 | getCoalescedEvents()、setPointerCapture() |
| 手ぶれ補正 | 指数移動平均(強度 0〜10) |
| ブラシ | Gペン / 毛筆 / ソフト / 水彩 / 鉛筆 / 消しゴム、直線・四角・円 |
| 表示操作 | DOMMatrix によるパン・ズーム・回転・左右反転、rAF で描画を間引く |
| レイヤー | 追加・削除・並べ替え(D&D)・結合、16 種類のブレンドモード、不透明度 |
| Undo / Redo | 矩形差分・構造参照・プロパティの 3 種類のコマンド、100 手 / 512MB 上限 |
| PSD | ag-psd で保存(合成画像・サムネイル付き)と読み込み(オフセット復元・グループ展開) |
| 画像書き出し | toBlob() による PNG / JPEG / WEBP |
| 操作 UI | 浮遊パッド、パイメニュー、キー割り当ての設定と JSON 入出力 |
まとめ
- 筆圧対応で本当に効くのは、入力の取りこぼし対策。
getCoalescedEvents()とsetPointerCapture()がないと、どんなブラシを作っても線が破綻する。 - 手ぶれ補正は、遅延と引き換えに滑らかさを得ている。この遅れは、払いの短さとして必ずどこかに現れる。
- Undo は最初から差分で設計する。全面スナップショットは実装こそ楽だが、2048² のキャンバスではすぐにメモリが足りなくなる。
- 入力の経路は 1 本にする。イベントの偽装で疎結合にしたつもりが、どこにも届かない入力を量産していた。
抜きの補完、筆圧カーブの調整、筆圧そのもののスムージングなど、書き心地の面ではまだやれることが多い。ただ、ひとまず「描けるもの」として公開する。