Blazor + canvas APIでアニメを描けるようにしてみた

# はじめに
Blazorなどの学習を兼ねてアニメを描けるシンプルなお絵描きWebアプリを作りました。
Blazorを使っているとは口ばかりで、C#よりもTypeScriptを書いてることの方が多いです。

[Demo(Firebase)](https://blazor-lightbox.web.app/)
[Source(GitHub)](https://github.com/msanou/animation-lightbox)

![blazorcanvas2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/711971/102a4aca-b67f-0a4d-d403-265e1b5e981d.gif)
某謎アニ団さんぐらいアニメを描けたら良かったのですが、私では瞬きが限界です。

現在は8fps(いわゆる三コマ打ち)のみですが、いずれはフレームレートを変更できるようにします。レートの変更だけなら楽ですが、アニメーション1秒の再生に24枚(24fps:日本のアニメの一般的なフレームレート)は普通描かないので、その空フレームに対する対応をする必要があります。

# 環境
Blazor WebAssembly 3.2.1 + .NET Standard 2.1
Microsoft.TypeScript.MSBuild 4.0.3
Firebase

他、Blazorのテンプレートに内包されてたBootstrapなど。

# 実装について
Blazorと言いながらBlazorで行っているのはUIの制御だけです。

## 線の描き方
Canvas上で線を描く実装方法を探していたところ、`lineTo()`のメソッドを使用した例が多かったのですが、ペンを素早く動かした時に変な描き方になることがあり、ペンの軌道に合わせて円を置いていく形になりました。
ただし、そのままでは素早く描こうとしたときに線が切れてしまうので、以下のように微小な変化量を使って間を埋めるようにしています。しかし、現在では直線的な補完の仕方なので、もう少し曲線的な形を考えたい。数学を復習する必要がありそう。

``` TypeScript
    private prevX :number;
    private prevY :number;
    public drawLineWithPen(x:number, y:number, isDrawing:boolean){
        let scaledX = x/this.scaleRate;
        let scaledY = y/this.scaleRate;
        if(!isDrawing) {
            this.context.beginPath();
            this.context.moveTo(scaledX,scaledY);
        } else {
            // 分割数
            let div = 200;
            let dx = (scaledX - this.prevX) / div;
            let dy = (scaledY - this.prevY) / div;
            let r = this.context.lineWidth/2;
            for(let i = 0; i<=div; i++){
                let x = this.prevX + dx*i;
                let y = this.prevY + dy*i;
                this.context.beginPath();
                this.context.moveTo(x,y);
                this.context.arc(x,y, r, 0, 2 * Math.PI, false);
                this.context.stroke();
                this.context.fill();
                this.context.closePath();
            } 
        }
        this.prevX = scaledX;
        this.prevY = scaledY;
    }
```

ペンの入り抜きに関しては時間の差分をパラメータとして円の大きさや透明度を変えていくような形を考えています。

## オニオンスキンの表示方法
オニオンスキンとは編集しているフレームの前後にあるフレームを特定の色で表示する機能です。今回は後方のフレームをピンク、前方のフレームを水色で表示しています。

あらかじめ保持しておいたImageDataの配列に対して、コピーを作り色の置換と透明度の設定を行っています。その後、Bitmapのデータを作成したのち、さらに`drawImage()`のメソッドを利用してオニオンスキン表示用のキャンバスへ書き込んでいます。`ImageData`で直接canvasに書き込めれば良かったのですが、`putImageData()`ではcanvasのすべてを置き換えてしまうのでこのような形になっています。

``` TypeScript
private setOnionSkinsInternal(start:number, end:number, color:Color, frames: ImageData[], isPrev:boolean){
    let startNum = Math.max(start, 0);
    let endNum = Math.min(end,frames.length);
    for(let i= startNum; i< endNum; i++) {
        let imageData = new ImageData(frames[i].data.slice(),frames[i].width,frames[i].height);
        for(let j=0;j<imageData.data.length; j+=4) {
            imageData.data[j] = color.r;
            imageData.data[j+1] = color.g;
            imageData.data[j+2] = color.b;
            // 現在のフレームから遠いフレームは透過度を強くして表示を薄くする
            if(isPrev)
                imageData.data[j+3] = imageData.data[j+3] * (i+1) / (endNum+1);
            else
                imageData.data[j+3] = imageData.data[j+3] * (startNum+1) / (i+1);          
        }
        window.createImageBitmap(imageData).then(
            (img) => {
                // scale()で設定した倍率分、imageがさらに縮小されるので、倍率で割る？
                this.context.drawImage(img,0,0,this.width/this.scaleRate,this.height/this.scaleRate);
            }
        ).catch(() => {
            console.log(`${i} Error`)});
    }
}
```

1枚あたり921,600かそれ以上のループが回るので、サイズを上げて10枚ほど表示しようとするとだいぶ重くなります。もう少し良い方法を考えたい。オニオンスキンの枚数分canvasを追加する……という方法も考えましたが試していません。

## 消しゴムとレイヤーの合成
https://hai3.net/blog/html5-canvas-eraser/
こちらの方法を参考にさせていただきました。ありがとうございます。

`CanvasRenderingContext2D.globalCompositeOperation = "destination-out";`
消しゴムでの描画時はこれを設定してあげればOKでした。

https://developer.mozilla.org/ja/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation
MDNを読む限り、レイヤーの合成やペン先の水彩っぽさなどはこれを用いて実現できそうな感じがします。

現在、消しゴムからペンへの切り替え方が、色を切り替えるかペン先サイズを変えるかしかないので、ちゃんとラジオボタン的な切替方法を作っておきたいと思います。

## undo/redo
履歴保存用の`ImageData[]`を持っておいて、undo/redoメソッドが呼び出されたその都度PopやPushするだけです。履歴の保存に関してはTypeScript側で全て行うことも考えましたが、UI側で制御したいと思い、呼ぶタイミングはBlazor側に任せてあります。現状、~~実装が楽なので~~各フレーム毎にそれぞれ履歴を持つようになっていますが、フレームの削除を実装するタイミングで履歴の持ち方を変えそうです。

``` TypeScript
public undo(){
    if(this.currentFrame.prevHistory.length > 0) {
        let prev = this.currentFrame.prevHistory.pop() ?? new ImageData(this.width,this.height) ;
        this.currentFrame.nextHistory.push(this.getImageData());
        this.putImageData(prev);      
    }
}

public redo(){
    if(this.currentFrame.nextHistory.length > 0){
        let next = this.currentFrame.nextHistory.pop() ?? new ImageData(this.width,this.height);
        this.currentFrame.prevHistory.push(this.getImageData());
        this.putImageData(next);
    }
}

public saveHistory(){
    this.currentFrame.prevHistory.push(this.getImageData());
    // 新しく履歴が追加された時、前方方向の履歴は削除する。
    this.currentFrame.nextHistory = [];
}
```

## canvas要素の優先順位
``` html
<canvas id="1"></canvas>
<canvas id="2"></canvas>
<canvas id="3"></canvas>
```
イベントを拾うのが`id="1"`の場合、一番下に持っていかないとイベントが拾えません。

## TypeScript + dotnet CLI
`dotnet add package Microsoft.TypeScript.MSBuild`のコマンドとjsonファイルの設定で`dotnet run`した時などに.tsファイルもC#のコードと共にコンパイルされるので非常に楽でした。
Fableを使ってF#で書くのも考えたのですが、メソッド名の生成方法からメソッド名を指定しなければいけないJavaScript相互運用の仕様上厳しそうなので諦めました。

# 今後追加する機能
GitHubのREADMEにやたらめったらと書きましたが、描き心地の問題は大きいので、線がカクついたり、入り抜きが一辺倒である問題は早めに解決したい。あとWeb Storage APIを使えば色やペンのサイズの設定値を保存できそうなので、そのあたりも対応していきたい。

タブレットやスマートフォン対応はやるとしてもだいぶ後の話になるでしょう。

# 私について
かつて、Windows Formアプリケーションを使ったシステム制作に関わっていたらしいニートです。
C#とCがほんの少しわかるだけで、TypeScriptなどの経験は特にありません。

## 作っている意図
某お絵かきソフトは気軽にアニメーションを描いて動かせないし、アニメ制作するにしても使いにくいし、画面効果もないし、意外とこちら方面に目を向けたお絵描き(Web)アプリは無さそうだなという考えから。また、データベースの操作やログイン機能などがありませんが、機会・質などの問題は別として、どこかで誰かに見せたい時に見せられる用です。

元々はCanvas APIのお試しでアニメ機能などは考えていませんでしたが、作っているうちに機能を載せたくなってきて今に至ります。

# おわりに
まだまだ実装したい機能や改善したい点など色々あるため、私のモチベーションが続く限りはやっていきたいですね。
TypeScriptはMicrosoftが開発に関わっているのもあり、C#に似ている部分があるのとdotnet CLIとの組み合わせもあって、コンパイル時にTypeScript側のエラーがある程度わかるのでだいぶ楽でした。Blazorも自身だけでブラウザのAPIに触れればいいのですが、IJSRuntimeを介してしかできないのでBlazor(C#)のみというのは厳しそうです。