Vim の DirectX をさらに高速化した話

これは、[Vim2 Advent Calendar 2017](https://qiita.com/advent-calendar/2017/vim2) 16日目の記事です。そして、koron さんの「[Vim の DirectX を速くした話](https://www.kaoriya.net/blog/2017/12/03/)」や vim-jp の「[Windows で色付きの絵文字が表示出来る様になりました。](http://vim-jp.org/blog/2017/11/27/windows-colored-emoji.html)」の後日談です。

カラー絵文字パッチ (& DirectX 高速化パッチ) が [8.0.1343](https://github.com/vim/vim/commit/d7ccc4d81dbcfa3ac0352bacea6e294fc9e33fda) としてマージされた後、DirectX 描画のさらなる高速化を進め、最終的に [8.0.1369](https://github.com/vim/vim/commit/92467d3351853de769329f62121bf34d28647546) としてマージされました。ここでは、その内容を説明します。

なお、これを読んでも Vim をよりよく使えるようになったりはしません (たぶん)。あと、無駄に長いです。

## 用語

DirectX というのは、「Direct何とか」という技術の総称で、Vim ではその内 DirectWrite と Direct2D を使用しています。
DirectWrite は高品位なテキストレンダリングライブラリで、カラー絵文字を表示するためにはこれを使う必要があります。Direct2D はハードウェアアクセラレーション対応の 2D グラフィクスライブラリで、DirectWrite はこの上に構築されています。

Vim 8.0 がリリースされた際の DirectX に対応したというアナウンスに対して、ゲームでもないのになぜ？という反応がありましたが、DirectWrite に対応したと言った方が誤解がなかったかもしれません。

## ボトルネック

koron さんの記事にある通り、8.0.1343 では、DirectWrite を使った文字の描画が大幅に高速化されたわけですが、実は特定の条件において、表示が大幅に遅くなる問題が残っていました。その条件とは、以下のいずれかに当てはまる場合です。

* 下線、打ち消し線、あるいは波線が表示されるとき。
* FixedSys などの (拡張子が .fon の) 旧式のビットマップフォントが選択されたとき。

前者に関しては、例えば、適当なファイルを開いて (`:help eval.txt` など)、`:set spell` でスペルチェックを有効化した状態で、<kbd>Ctrl</kbd> + <kbd>F</kbd> を押しっぱなしにしてスクロールを行うと、スペルエラーの波線が大量に表示される部分でスクロール速度が大幅に低下する現象が発生していました。

これらの飾り線や旧式フォントによる文字の表示はいずれも GDI で描画を行っており、GDI と DirectWrite の間で描画の切り替わりが発生することで速度低下が発生していました。

## 対策

GDI / DirectWrite 間の描画切り替わりが速度低下の原因であるならば、高速化のためには切り替わりをできるだけ減らせばよいということになります。そこで取った方針とは、以下の 3 つの描画モードを使うことでした。

1. GDI デバイスコンテキスト (HDC) への GDI による描画 (GDI 描画モード)  
   このモードは極力使わない。
   原則として、描画が完了しそれを表示するときのみ、このモードとなる。
2. GDI デバイスコンテキストへの Direct2D & DirectWrite による描画 (DirectX 描画モード)  
   可能な限りこのモードを使って描画する。
   文字は DirectWrite を使い、下線、打ち消し線、波線、矩形塗りつぶしなどは Direct2D で描画を行う。
3. Direct2D GDI 互換レンダーターゲットへの GDI への描画 (interop 描画モード)  
   旧式フォントが使われた場合はこのモードを使う。
   文字、下線、打ち消し線、波線、矩形塗りつぶしなどは GDI で描画を行う。

モード 2 からモード 1 への遷移は非常に遅く (手元のノート PC だと定常状態で 0.8 ～ 1.5ms 程度)、これを減らすことが最も重要な課題でした。(ただし、モード 2 での描画が終わりそれを表示するためには、必ずモード 1 に遷移しなければいけません。)
FixedSys などの旧式フォントは DirectWrite が対応していないため、GDI での描画が必須ですが、そこで用意したのがモード 3 です。モード 2 とモード 3 の遷移もそこそこ遅く、これを減らすこともまた重要でした。

詳細は割愛しますが、モードの遷移は以下の API と対応しています。

* 1 → 2: [ID2D1DCRenderTarget](https://msdn.microsoft.com/ja-jp/library/windows/desktop/dd371213.aspx)::[BeginDraw()](https://msdn.microsoft.com/ja-jp/library/windows/desktop/dd371768.aspx)
* 2 → 1: ID2D1DCRenderTarget::[EndDraw()](https://msdn.microsoft.com/ja-jp/library/windows/desktop/dd371924.aspx)
* 2 → 3: [ID2D1GdiInteropRenderTarget](https://msdn.microsoft.com/ja-jp/library/windows/desktop/dd371321.aspx)::[GetDC()](https://msdn.microsoft.com/ja-jp/library/windows/desktop/dd371323.aspx)
* 3 → 2: ID2D1GdiInteropRenderTarget::[ReleaseDC()](https://msdn.microsoft.com/ja-jp/library/windows/desktop/dd371327.aspx)

最終的に、(少々無駄な感じはしますが)それぞれのモードに下線、打ち消し線、波線、矩形塗りつぶしなどの処理を用意することにしました。これにより、モードを変更せずに描画を続けることができるようになり、モード間の遷移を減らすことで大幅な速度改善を達成できました。
ちなみに、波線を描く際は点を 1 つずつ打って描いているのですが、Direct2D には点を打つ API がないため、長さ 1 ピクセルの直線を引くことで点を打つ代用としました。

ところで、Vim の描画は Direct2D & DirectWrite には完全移行はしておらず、HDC に対して Direct2D & DirectWrite で描画を行っているわけですが、モード 3 は、HDC 互換の Direct2D 用のバッファに対し、さらに Direct2D 連携用の HDC を用意して GDI で描画を行うという 2 段階の構成になっているところが笑えるところです。
描画を Direct2D & DirectWrite に完全移行 ([ID2D1HwndRenderTarget](https://msdn.microsoft.com/ja-jp/library/windows/desktop/dd371461.aspx) を使う) すれば、描画結果を HDC に反映するためのオーバーヘッドが無くなってさらに高速化される可能性がありますが、今回はそこまではできていません。(ただ、実験的に試した範囲では、あまり速度が変わらない感じがしましたが…。)

## スクロールの高速化

今まで Vim では、スクロールに ScrollWindowEx という API を使っていました。これを使う場合、モード 2 での描画を完了してモード 1 に遷移し、スクロールを行ってから、モード 2 に遷移してスクロール後の空いた領域に描画を行って、再度モード 1 に戻って表示、というプロセスが必要になります。
いろいろと試した結果、環境によっては、スクロール対象の領域をスクロールせずに丸ごと再描画してしまった方が、モードの遷移が減って高速になることが分かりました。

8.0.1369 からは、

```vim
set renderoptions=type:directx,scrlines:1
```

のように、`scrlines` オプションを指定すると、この動作を制御することができるようになりました。(追記: 8.0.1449 で廃止。下記の後日談を参照。)
`scrlines:0` の場合は常に従来通りスクロールを行い、1 を指定するとスクロール領域全体を再描画するようになります。2 以上を指定すると、スクロール行数が指定した値以上の場合は再描画を行い、それよりも少なければスクロールを行います。

DirectX を使うことで GDI に比べて画面単位のスクロールが遅くなったと感じるならば、`scrlines:1` に設定してみてください。この状態で、`:version` や `:highlight` コマンドの出力などの 1 行ずつのスクロールが遅くなったと感じるようであれば、値を増やしてみてください。

残念ながら、環境によってはこのチューニングを行っても、DirectX でのスクロールが GDI より遅い場合もあります。また、表示内容によってもスクロールが遅くなる場合があります。(例: `:digraphs`)

## カラー絵文字パッチ制作の経緯の裏側

ここでちょっと話を戻して、「[カラー絵文字パッチ制作の経緯](https://www.kaoriya.net/blog/2017/11/27/)」について、私の視点からの補足をしておきましょう。

koron さんと mattn さんが、カラー絵文字を表示するサンプルを Vim に組み込んだりしていたころ、私は別方向からのアプローチを試みていました。

Vim では、すべての文字は 1 セルまたは 2 セル分の幅で表示されることが大前提となっています。

DirectWrite でも、等幅フォントを使用すれば全ての文字は同じ幅で表示されるように思えます。しかし、表示しようとした文字のグリフが指定したフォントに含まれていない場合、DirectWrite は自動的に別のフォントを使って文字を表示しようとします。また、Vim には ['guifontwide'](http://vim-jp.org/vimdoc-ja/options.html#'guifontwide') というオプションがあり、全角幅の文字に対して別のフォントを使うこともできます。このような場合に DirectWrite をそのまま使うと、文字の幅が合わずに表示がずれてしまいます。
この問題に対処するため、Vim では 1 文字ずつ位置を調整しながら描画を行っています。(ソースコードでは、`AdjustedGlyphRun` クラスがそれに相当。)

koron さんらが試していた [ID2D1RenderTarget::DrawText()](https://msdn.microsoft.com/ja-jp/library/windows/desktop/dd742848.aspx) API に、[D2D1_DRAW_TEXT_OPTIONS_ENABLE_COLOR_FONT](https://msdn.microsoft.com/en-us/library/windows/desktop/dd368095.aspx) を指定する方法は、簡単にカラー絵文字を表示することはできるのですが、1 文字単位での位置調整ができないため、このままでは Vim に正式採用できないのは明白でした。

そこで私は (カラー絵文字は完全に 2 人に任せておいて)、文字位置の調整をしながらビットマップに描画していた部分を、(GDI 互換の) Direct2D レンダーターゲットに直接描画するように変更することに注力しました。それが何とか動くようになったのが、[vim-jp の slack](https://vim-jp.slack.com/) の #color-emoji チャンネルの、11/23 時点のこの発言でした。(なお、#color-emoji チャンネルは現在では #dev に改名されています。)

> Ken Takata 12:14 PM
> 一応、手元では bmpRT を使わずに、mRT に対して GlyphRun で書くのは動いた。カラー絵文字や高速化には対応していませんが。

koron さんが私の修正を取り込み、それと高速化を組み合わせて出来上がったのが以下の `type:directx4` です。

> KoRoN 4:23 PM
> 波線とかできた。
> ここまで作ったものまとめ
> `type:directx2` めっちゃ遅いけど、とりあえず色付き絵文字が表示できる
> `type:directx3` 色付き絵文字が表示できて速いけど、alignがおかしい
> `type:directx4` 速くてalignはできているけど、色付き絵文字は表示できない
> 3と4のハイブリッドが、次の目的。それは `type:directx4` (DrawText4) を改造して実装する。

この時点では、デバッグ用に複数の方式を切り替えられるようになっていました。
この後、mattn さんが `type:directx4` にカラー絵文字対応を追加したことで、一通りの機能が完成し、最終的にはこれをベースにしたものが 8.0.1343 として取り込まれました。

## まとめ

今回の高速化のポイントは以下の通りです。

* Direct2D で描画する際は、BeginDraw(), EndDraw() の呼び出しをできるだけ減らす。
* Direct2D の GDI 互換レンダーターゲットに GDI で描画する際は、GetDC(), ReleaseDC() の呼び出しをできるだけ減らす。
* 環境によっては、スクロールするよりも再描画してしまった方が速い。Vim 8.0.1369 からは `scrlines` を設定することで、スクロールの動作をチューニングできる。

今後の課題としては、以下があります。

* Direct2D & DirectWrite への完全移行。
* スクロール速度のさらなる改善。

また、Vim 8.0.1369 のリリースからしばらく経ったことで、新たにいくつか問題も見つかりました。

* ~~Vim 8.0.1369 で逆に遅くなってしまった環境が[あるらしい](https://groups.google.com/d/msg/vim_dev/2AaZMN1T6K8/x4QxC_XbBQAJ)。~~ <br/>
  Vim 8.0.1343 で逆に遅くなってしまう環境が[ある](https://groups.google.com/d/msg/vim_dev/2AaZMN1T6K8/C6TKvc5VBAAJ)。
* スクロールバーなどを使ってスクロールすると、カーソルが一瞬違った場所に表示される。
* 文字の下端が切れる場合がある。(8.0.1390 で直ったはず？)

これらについては、引き続き vim-jp にて改善していく予定です。開発に興味のある方は、[vim-jp の slack](https://vim-jp.slack.com/) の #dev チャンネルに参加してみてください。(slack への参加方法は、「[vim-jpのチャットルームについて](http://vim-jp.org/docs/chat.html)」を参照してください。)

## 後日談 (2018/03/06 追記)

8.0.1343 で遅くなってしまった環境を調査したところ、EndDraw() が他の環境に比べて特に遅く (5ms 以上)、さらにこれが不必要に呼ばれていることが判明しました。そこで不要な呼び出しを徹底的に排除したところ、速度が改善し、[8.0.1449](https://github.com/vim/vim/commit/a338adcf222b6a24e26ea5ae6a2ad27f914acb38) (および追加修正の [8.0.1476](https://github.com/vim/vim/commit/acda04f5c641330cd589ca52eb61d1ab0d62385f)) として取り込まれました。これにより 8.0.1343, 8.0.1369 で見つかっていた上記の問題 3 件はほぼ解消されました。
またスクロールについても、GDI によるスクロールをやめて Direct2D でスクロールすることにしたことで速度が改善し、それに伴い `scrlines` オプションは廃止されました。(指定しても無視されます。)

今後 DirectWrite を使う場合は、ぜひ 8.0.1476 以降をお使いください。