はじめに
最近、私は atomlgfx という小さなライブラリを作っています。
atomlgfx は、AtomVM 上の Elixir から LovyanGFX を使うための橋渡しです。LovyanGFX は ESP32 などで液晶表示器を扱うための強力な C++ の描画基盤です。
きっかけは、私が参加している Elixir の技術共同体である Piyopiyo.ex で、Elixir と IoT を組み合わせた実験が増えてきたことでした。小さな機器の液晶表示器に、Elixir から文字や図形やアニメーションを描きたい。その流れの中で、 ある方から LovyanGFX を教えていただきました。
最初は「Elixir の関数を LovyanGFX の関数に対応させればよい」と考えていました。しかし、実際に作ってみると、それだけでは済まないことが分かってきました。
この記事では、atomlgfx を作りながら学んだことを、自分の整理として書きます。
やりたかったこと
目的は、Elixir を書く人が C/C++ を直接触らなくても、LovyanGFX の力を使えるようにすることです。
たとえば、Elixir 側では次のように書けると嬉しいです。
AtomLGFX.fill_rect(port, 10, 10, 80, 40, 0xF800)
AtomLGFX.draw_line(port, 0, 0, 120, 80, 0xFFFF)
AtomLGFX.display(port)
一方で、裏側では LovyanGFX の C++ の描画処理を呼び出します。
つまり、atomlgfx は「Elixir の読みやすさ」と「LovyanGFX の描画能力」をつなぐための薄い橋渡しです。
Elixir で描画命令を書く
|
v
atomlgfx が Elixir と C/C++ の橋渡しをする
|
v
C/C++ 側で LovyanGFX を呼ぶ
|
v
液晶表示器に描画する
最初に考えた素直な設計
最初に考える設計は単純です。
Elixir の関数を1回呼ぶ
-> AtomVM のポートを通る
-> C/C++ 側で LovyanGFX の関数を1回呼ぶ
これは分かりやすく、保守もしやすいです。
たとえば fill_rect を呼んだら、C/C++ 側で LovyanGFX の fillRect を呼ぶ。draw_line を呼んだら、C/C++ 側で drawLine を呼ぶ。
この形は、設定、回転、明るさ、文字、タッチ、スプライト管理のような処理には向いています。呼び出し回数が少なく、読みやすさの利点が大きいからです。
アニメーションで見えてきた壁
問題は、アニメーションでした。
アニメーションでは、一つの画面を作るために小さな描画命令を何十回、何百回も呼ぶことがあります。
四角を描く
線を描く
点を描く
前の位置を消す
次の位置を描く
また線を描く
また四角を描く
...
このとき、毎回 Elixir から C/C++ 側へ境界を越えると、その計算量が目立ってきます。
実際に作ってみると、性能に影響する要素はいくつもありました。
- Elixir/AtomVM 側で描画命令を組み立てる処理
- 仮想機械とネイティブ側を行き来する回数
- タプルや一覧を読み解く処理
- 色形式や画素数によるデータ量
- 液晶表示器の解像度
- 実際に表示器へ送る描画量
中でも大きかったのは、LovyanGFX の描画処理そのものだけではなく、「小さな描画命令を何回に分けて渡すか」でした。
つまり、単に「C++ は速い」「Elixir は遅い」という話ではありませんでした。どこで何を処理し、どれだけ境界を越えるかが重要でした。
ElixirとC/C++の役割分担
この作業を通じて、Elixir の良さも改めて感じました。
Elixir は、読みやすさとパターンマッチが強い言語です。描画命令の形、引数の意味、使ってよい命令名などは、Elixir 側でかなり分かりやすく表現できます。
たとえば、Elixir 側では次のような責務を持たせられます。
Elixir側:
読みやすい公開関数
パターンマッチ
ガード
命令名の正規化
引数の形の確認
分かりやすいエラー
一方で、C/C++ 側では安全のための確認を残します。
C/C++側:
壊れた入力で危険な処理をしない
バイナリの範囲外を読まない
未初期化の表示器を操作しない
不正な対象に描画しない
LovyanGFXへ渡す直前の安全性を確認する
この分担は、次のように考えると整理しやすくなりました。
Elixirは、描画命令の意図を分かりやすく表す。
C/C++は、LovyanGFXへ渡す直前の安全性を守る。
使いやすい規約は Elixir 側に寄せつつ、危険な入力で壊れないための確認は C/C++ 側にも残します。
速さを左右する「呼び出しの粒度」
性能を考えるうえで大きかったのは、1回の呼び出しでどれだけの描画命令を渡すかでした。
最初は、Elixir の関数呼び出しをそのまま LovyanGFX の関数呼び出しに対応させる形を考えていました。
1命令ずつ送る場合
Elixir -> C/C++ -> LovyanGFX
Elixir -> C/C++ -> LovyanGFX
Elixir -> C/C++ -> LovyanGFX
この形は分かりやすい一方で、描画命令が増えるほど、AtomVM とネイティブ側の境界を越える回数も増えます。
そこで、描画命令をまとめて渡す方法を考えました。
まとめて送る場合
Elixir -> [描画命令列] -> C/C++ -> LovyanGFX x N
方法はいくつかあります。
1命令ずつ呼ぶ
分かりやすいが、境界を越える回数が多い
複数の描画命令をまとめる
呼び出し回数を減らせる
描画命令を軽量なバイナリ形式で渡す
繰り返し実行される描画処理を軽くできる
画面全体の描画をC++専用処理にする
最速に近いが、汎用性は下がる
最速だけを考えるなら、Elixir からは「この画面を描いて」とだけ送り、C++ 側ですべて描けばよいです。
しかし、それでは LovyanGFX の汎用的な Elixir 向け橋渡しではなく、特定用途向けの専用描画処理になってしまいます。
私が作りたいのは、Elixir から LovyanGFX を扱うための汎用的な道具です。そのため、最速だけを追うのではなく、読みやすさ、柔軟性、性能の落とし所を探すことにしました。
そこで重要になったのが、「1命令ずつ渡す」のではなく、「まとまった描画の仕事として渡す」という考え方でした。
現在選んでいる設計
現在は、用途に応じて二つの経路を使い分ける方針にしています。
通常の経路:
設定、回転、明るさ、文字、タッチ、スプライト管理など
-> Elixir から読みやすく呼ぶ
まとめて描画する経路:
fill_rect や draw_line など、繰り返し使う描画命令
-> 描画命令を小さなバイナリ形式でまとめ、1回で C/C++ 側へ送る
速い経路では、Elixir 側で描画命令列を小さなバイナリとして組み立てます。
Elixir が小さな命令バイナリを作る
-> AtomVM のポートへ1回だけ送る
-> C/C++ 側でバイナリを順に読む
-> LovyanGFX の描画関数を繰り返し呼ぶ
この形にすると、仮想機械の境界を越える回数を減らせます。
一つ一つの描画命令を個別に送るのではなく、まとまった描画処理として渡す。これが、今回の実装で特に大きな学びでした。
アニメーションでは毎回全部消す必要はない
液晶表示器でアニメーションを作るとき、毎回画面全体を消してから描くのは分かりやすい方法です。
画面全体を消す
次の画面を描く
ただし、画面が大きくなると、この方法は重くなります。
実際に必要なのは、画面全体を毎回消すことではなく、前のフレームで変わった画素を次のフレームの正しい状態に戻すことです。
方法はいくつかあります。
全画面を毎回描き直す
分かりやすいが、描画量が多くなりやすい
前回の描画範囲を背景に戻す
背景が単純な小さな動きに向いている
変わった範囲だけ再描画する
背景や重なりも含めて正しく戻したい場合に向いている
スプライトに描いてから表示する
途中の描画が見えにくく、ちらつきを減らしやすい
用途に特化したC++描画処理にする
速くしやすいが、汎用性は下がる
ここでも正解は一つではありません。atomlgfx では、Elixir 側で何を更新するかを決め、C/C++ 側でまとめて実行する形が良さそうだと感じています。
実機で見えたこと
実機で簡単な性能確認をしたところ、小さな描画命令を1回ずつ送るより、まとめてバイナリとして送る方が大きく速くなりました。
手元の環境では、fill_rect や draw_line を120回呼ぶ比較で、まとめて送る経路がかなり有利でした。仮想機械の境界を越える回数を減らす効果は大きいと感じました。
一方で、まとめれば常によいわけでもありません。MovingIcons のデモでは、表示対象を obj=50 程度に増やしたとき、メモリ不足に悩まされました。呼び出し回数を減らせても、Elixir 側で持つ描画命令や状態が増えすぎると、今度はメモリ使用量が問題になります。
つまり、呼び出し回数、データ量、メモリ使用量のバランスを見ることが大事です。Elixir は何を描くかを分かりやすく決め、C/C++ は繰り返し実行される描画処理をまとめて処理する。この分担が、atomlgfx では現実的な落とし所だと感じました。
おわりに
atomlgfx を作る中で特に印象に残ったのは、性能と柔軟性の間にはっきりした交換条件があることでした。
Elixir から1命令ずつ呼ぶ形は分かりやすい一方で、境界を越える回数が増えます。すべてを C/C++ に寄せれば速くしやすい一方で、汎用性は下がります。
その間の選択肢として、Elixir で描画の意図や通信規約を分かりやすく表し、C/C++ で繰り返し実行される描画処理をまとめて処理する、という形が見えてきました。
Elixirで意図を表現する。
C/C++で繰り返し描画をまとめる。
境界を越える回数を減らす。
Elixir を捨てて速くするのではなく、Elixir の得意なところと C/C++ の得意なところを分ける。この考え方を大切にしながら、引き続き atomlgfx を育てていきたいと思います。
