LovyanGFX入門 その9 描画速度計測

概要

前回は全く関係ない画面キャプチャの話題でした。今回はいろいろな描画の時間を計測して、どんな処理になっているのかを確認してきたいと思います。

スケッチ例1(通常描画)

ちょっと長いので、スケッチは別の場所に置きます。

drawPixel() : 451027
drawPixel() + setColor() : 437125
drawPixel() + startWrite(): 126832
drawPixel() + startWrite() + setColor() : 118255
fillRect() : 16074
fillRect() + startWrite() : 16074
sprite drawPixel() : 29290
sprite drawPixel() + setColor() : 13288
sprite drawPixel() + startWrite(): 29923
sprite drawPixel() + startWrite() + setColor() : 13963
sprite fillRect() + startWrite() : 240
sprite fillRect() : 212
sprite pushSprite() : 16159
spritePSRAM drawPixel() : 40610
spritePSRAM drawPixel() + setColor() : 24207
spritePSRAM drawPixel() + startWrite(): 40003
spritePSRAM drawPixel() + startWrite() + setColor() : 24017
spritePSRAM fillRect() + startWrite() : 10655
spritePSRAM fillRect() : 10644
spritePSRAM pushSprite() : 16725
img pushImage() : 16746
imgOnMem pushImage() : 16908
imgPSRAM pushImage() : 16836
img      0x3f404680
imgOnMem 0x3ffe4374
imgPSRAM 0x3f8138b8

上記が実行結果です。内容を個別に解説していきたいと思います。

Lcd.drawPixel()

200×200の4万ピクセルを塗りつぶすサンプルです。

条件経過時間(us)
drawPixel()451,027
drawPixel() + setColor()437,125
drawPixel() + startWrite()126,832
drawPixel() + startWrite() + setColor()118,255
  for (int y = 0; y < 200; y++) {
    for (int x = 0; x < 200; x++) {
      M5Lite.Lcd.drawPixel(x, y, TFT_RED);
    }
  }

Lcdに対して4万回描画した場合451ミリ秒も時間がかかっています。これだと秒間2フレームしか描画できませんね。これはLcdはdrawPixel()で実際に画面に転送されてしまうので、転送待ち時間が非常に長くなっています。

  M5Lite.Lcd.setColor(TFT_BLUE);
  for (int y = 0; y < 200; y++) {
    for (int x = 0; x < 200; x++) {
      M5Lite.Lcd.drawPixel(x, y);
    }
  }

2行目はsetColor()をしています。drawPixel()関数で色指定をした場合には、内部でsetColor()を呼んでから描画をしています。同じ色を塗る場合にはsetColor()を省略することができますので、若干早くなっています。

  M5Lite.Lcd.startWrite();
  for (int y = 0; y < 200; y++) {
    for (int x = 0; x < 200; x++) {
      M5Lite.Lcd.drawPixel(x, y, TFT_RED);
    }
  }
  M5Lite.Lcd.endWrite();

上記は描画処理をstartWrite()とendWrite()で囲んであります。データベースのトランザクションのように、SPI通信を専有することを宣言しています。宣言していない場合には各drawPixel()でSPI通信が利用可能かを確認する必要があります。今回は4万回もSPI通信をしていますので非常に差がでています。基本的にはstartWrite()とendWrite()は利用するようにしたほうがよいと思います。

Lcd.fillRect()

条件経過時間(us)
fillRect()16,074
fillRect() + startWrite()16,074

200×200の4万ピクセルを塗りつぶすサンプルです。あまり時間が変わっていません。

  M5Lite.Lcd.startWrite();
  M5Lite.Lcd.fillRect(0, 0, 200, 200, TFT_BLUE);
  M5Lite.Lcd.endWrite();

こちらの場合には一回しかSPI通信をしないんで、色指定やstartWrite()などの影響をほとんど受けません。

sprite.drawPixel()

条件経過時間(us)
sprite drawPixel()29,290
sprite drawPixel() + setColor()13,288
sprite drawPixel() + startWrite()29,923
sprite drawPixel() + startWrite() + setColor()13,963

処理はLcdと同じなのですが、オンメモリ上に描画しているのでSPI通信は発生していません。そのためstartWrite()の影響は受けません。setColor()はLcdと同じ程度の時間が短縮されています。

sprite.fillRect()

条件経過時間(us)
sprite fillRect() + startWrite()240
sprite fillRect()212

こちらもあまり差がありません。誤差の範囲ですが実際はstartWrite()の処理がある分だけ遅くなっているはずです。

sprite.pushSprite()

条件経過時間(us)
sprite pushSprite()16,159

オンメモリで描画してたスプライトをLcdに転送する処理です。

  sprite.pushSprite(0, 0);

fillRect()で同色で塗り潰したときと同じぐらいの時間がかかっています。

spritePSRAM.drawPixel()

条件経過時間(us)
spritePSRAM drawPixel()40,610
spritePSRAM drawPixel() + setColor()24,207
spritePSRAM drawPixel() + startWrite()40,003
spritePSRAM drawPixel() + startWrite() + setColor()24,017

オンメモリではなく、SPI接続のPSRAMにスプライトを確保した場合の時間です。こちらもsetColor()は早くなっていますが、スプライトなのでstartWrite()は影響を受けません。全般的にオンメモリのスプライトと比べると遅くなっているのがわかると思います。

spritePSRAM.fillRect()

条件経過時間(us)
spritePSRAM fillRect() + startWrite()10,655
spritePSRAM fillRect()10,644

こちらも同様の傾向です。

spritePSRAM.pushSprite()

条件経過時間(us)
spritePSRAM pushSprite()16,725

Lcdに転送した結果です。実はこの時間はオンメモリからの時間とあまり変わりません。Lcdの転送時間が支配的なんだと思われます。

pushImage()

条件経過時間(us)
img pushImage()16,746
imgOnMem pushImage()16,908
imgPSRAM pushImage()16,836

こちらもびっくりなんですが、200×200の画像ファイルをLcdに描画した場合の時間です。オンメモリからだろうと、PSRAMからだろとフラッシュからだろうとあまり変わりませんでした。

スケッチ例2(画面全体描画)

先程は通常の描画でしたが、M5Stack Core2の320×240の画面を3分割して、320×80を3回描画して画面全体を更新するサンプルです。

clear()

条件経過時間(us)
clear()30,842
clear() + startWrite()30,839

画面全体を同じ色で塗りつぶす関数です。fillScreen()と同等の処理でfillRect()で画面全体を指定しても同じ処理です。こちらも一回のSPI通信なのでstartWrite()の影響は受けていません。

pushSprite()

条件経過時間(us)
pushSprite()31,391
pushSprite() + startWrite()30,903
pushPixels()31,470
pushPixels() + startWrite()31,078
pushPixelsDMA()31,188
pushPixelsDMA() + startWrite()30,883
pushSprite() + delay()45,044
pushSprite() + delay() + startWrite()30,902

スプライトの転送ですが、pushSprite()の他にも各種条件チェックをせずに高速に転送をするpushPixels()関数と、DMA転送専用のpushPixelsDMA()があります。

  sprite[0].pushSprite(0, 0);
  M5Lite.Lcd.pushPixels((uint16_t*)sprite[0].getBuffer(), 320 * 80);
  M5Lite.Lcd.pushPixelsDMA((uint16_t*)sprite[0].getBuffer(), 320 * 80);

上記のように呼び出しがちょっと異なります。結果を見てみるとどの関数でもあまり時間は変わっていませんでした。

  sprite[0].fillScreen(TFT_RED);
  sprite[0].pushSprite(0, 0);
  delay(5);
  sprite[1].fillScreen(TFT_BLUE);
  sprite[1].pushSprite(0, 80);
  delay(5);
  sprite[0].fillScreen(TFT_RED);
  sprite[0].pushSprite(0, 160);
  delay(5);

唯一遅いのは上記のpushSprite()のあとにdelay(5)と5ミリ描画3回で15ミリ秒の遅延を入れた場合です。この場合は描画が約30ミリ秒、遅延が15ミリ秒と他の結果と同じ時間になります。

  M5Lite.Lcd.startWrite();
  sprite[0].fillScreen(TFT_RED);
  sprite[0].pushSprite(0, 0);
  delay(5);
  sprite[1].fillScreen(TFT_BLUE);
  sprite[1].pushSprite(0, 80);
  delay(5);
  sprite[0].fillScreen(TFT_RED);
  sprite[0].pushSprite(0, 160);
  delay(5);
  M5Lite.Lcd.endWrite();

同じような処理なのですが、startWrite()とendWrite()で囲むとなんと約30ミリ秒と描画だけの時間になり、delay()の影響を受けなくなります。これは実はpushSprite()は一瞬で完了しており、バックグラウンドでDMA転送をされています。そのためdelay(5)で遅延しても総描画時間には影響がなく、次のpushSprite()やendWrite()の先頭でDMA転送が終わるまで待機しているのです。

ここはちょっとわかりにくいので、次のスケッチ例で解説をしたいと思います。

spritePSRAM.pushSprite()

条件経過時間(us)
spritePSRAM pushSprite()48,584
spritePSRAM pushSprite() + startWrite()46,493
spritePSRAM pushPixels()45,937
spritePSRAM pushPixels() + startWrite()45,134
spritePSRAM pushPixelsDMA()49,106
spritePSRAM pushPixelsDMA() + startWrite()37,525
spritePSRAM pushSprite() + delay()62,739
spritePSRAM pushSprite() + delay() + startWrite()60,136

同じ用に今度はオンメモリではなくPSRAMに確保したスプライトで実験します。全般的にメモリの速度が違うので遅くなっています。

そして、delay()が入った例ではstartWrite()を使っても早くなっていません。これはPSRAMからはDMA転送ができないので、delay()の分がそのまま遅延されてしまっています。

また、pushPixelsDMA()ですが、PSRAMからはDMA転送ができないので描画時間的には問題ないのですが実際に描画されている画像は崩れています。つまり明示的にDMA転送をさせる関数なのですが、最速のはずですがDMA転送ができるかのチェックもされていないので、PSRAMから実行するとデータが正しく描画されません。若干チェックのオーバーヘッドがありますが、pushSprite()を利用することで、DMA転送が可能な場合にはDMA転送を利用すると判定してくれるので便利だと思います。

スケッチ例3(処理別処理時間)

上記に個別の関数単位での経過時間を取得するスケッチがあります。

コマンド開始時間実行時間
M5Lite.Lcd.startWrite()277650412
sprite[0].fillScreen(TFT_RED)2776516172
sprite[0].pushSprite(0, 0)2776688168
M5Lite.Lcd.waitDMA()277685610,238
sprite[1].fillScreen(TFT_BLUE)2787094155
sprite[1].pushSprite(0, 80)278724914
M5Lite.Lcd.waitDMA()278726310,240
sprite[0].fillScreen(TFT_RED)2797503142
sprite[0].pushSprite(0, 160)279764516
M5Lite.Lcd.waitDMA()279766110,240
M5Lite.Lcd.endWrite()28079015
全体280790631,402

全体では31ミリ秒かかっています。個別に時間を見ていくとpushSprite()関数は一瞬で終わっています。そしてwaitDMA()でDMA転送が終わるまで待機している時間が約10ミリ秒ですね。

320×80の画像を転送する時間は約10ミリ秒で、DMA転送なのでバックグラウンド転送されています。つまりCPUは自由に使える状況ですので、その間に転送しているのとは別のスプライトに描画をすることができます。今回は塗り潰しだけなのであまり意味がないですが、LovyanGFXの公式スケッチ例にあるMovingIconsなどはこのDMA転送待ちの間に、次に転送するスプライトを作成することで高速描画が可能です。

これは実験のため、waitDMA()を入れていますが実際には入れないほうが早いです。入れない場合には次回のpushSprite()かendWrite()で自動的にwaitDMA()が実行されます。そうすることでfillScreen()などの描画している時間がDMA転送と並行になり時間短縮されます。

PSRAMの場合

  sprite[0].setPsram(true);
  sprite[0].createSprite(320, 80);
  sprite[1].setPsram(true);
  sprite[1].createSprite(320, 80);

スケッチ例のスプライト作成時にPSRAMを有効にして作成してみます。この状況で同じ処理を実行してみました。

コマンド開始時間実行時間
M5Lite.Lcd.startWrite()278792912
sprite[0].fillScreen(TFT_RED)27879416,839
sprite[0].pushSprite(0, 0)279478010,845
M5Lite.Lcd.waitDMA()28056254
sprite[1].fillScreen(TFT_BLUE)28056294,791
sprite[1].pushSprite(0, 80)281042010,724
M5Lite.Lcd.waitDMA()28211446
sprite[0].fillScreen(TFT_RED)28211504,774
sprite[0].pushSprite(0, 160)282592410,738
M5Lite.Lcd.waitDMA()28366627
M5Lite.Lcd.endWrite()28366694
全体283667348,744

全体の実行時間が31ミリ秒から、48ミリ秒に伸びました。個別に見るとpushSprite()に時間がかかっており、waitDMA()は一瞬で終わっています。つまりDMA転送がされていないので、pushSprite()はCPU時間を使って転送されています。

スケッチ例4(関数別処理時間例)

処理時間(us)コマンド
91sprite.clear();
523sprite.drawArc      (x, y, r0, r1, angle0, angle1);  // 円弧の外周
705sprite.drawArc      (x, y, r0, r1, angle0, angle1, color);  // 円弧の外周
176sprite.drawBezier   (x0, y0, x1, y1, x2, y2); // 3点間のベジエ曲線
238sprite.drawBezier   (x0, y0, x1, y1, x2, y2, color); // 3点間のベジエ曲線
425sprite.drawBezier   (x0, y0, x1, y1, x2, y2, x3, y3); // 4点間のベジエ曲線
528sprite.drawBezier   (x0, y0, x1, y1, x2, y2, x3, y3, color); // 4点間のベジエ曲線
26sprite.drawCircle   (x, y      , r); // 円の外周
54sprite.drawCircle   (x, y      , r, color); // 円の外周
33sprite.drawEllipse  (x, y, rx, ry ); // 楕円の外周
64sprite.drawEllipse  (x, y, rx, ry , color); // 楕円の外周
7sprite.drawFastHLine(x, y, w      ); // 水平線
21sprite.drawFastHLine(x, y, w      , color); // 水平線
3sprite.drawFastVLine(x, y   , h   ); // 垂直線
18sprite.drawFastVLine(x, y   , h   , color); // 垂直線
22sprite.drawLine     (x0, y0, x1, y1        ); // 2点間の直線
50sprite.drawLine     (x0, y0, x1, y1        , color); // 2点間の直線
5sprite.drawPixel    (x, y         ); // 点
12sprite.drawPixel    (x, y         , color); // 点
16sprite.drawRect     (x, y, w, h   ); // 矩形の外周
25sprite.drawRect     (x, y, w, h   , color); // 矩形の外周
64sprite.drawRoundRect(x, y, w, h, r); // 角丸の矩形の外周
100sprite.drawRoundRect(x, y, w, h, r, color); // 角丸の矩形の外周
117sprite.drawTriangle (x0, y0, x1, y1, x2, y2); // 3点間の三角形の外周
125sprite.drawTriangle (x0, y0, x1, y1, x2, y2, color); // 3点間の三角形の外周
137sprite.fillArc      (x, y, r0, r1, angle0, angle1);  // 円弧の塗り
158sprite.fillArc      (x, y, r0, r1, angle0, angle1, color);  // 円弧の塗り
63sprite.fillCircle   (x, y      , r); // 円の塗り
80sprite.fillCircle   (x, y      , r, color); // 円の塗り
61sprite.fillEllipse  (x, y, rx, ry ); // 楕円の塗り
85sprite.fillEllipse  (x, y, rx, ry , color); // 楕円の塗り
137sprite.fillRect     (x, y, w, h   ); // 矩形の塗り
139sprite.fillRect     (x, y, w, h   , color); // 矩形の塗り
244sprite.fillRoundRect(x, y, w, h, r); // 角丸の矩形の塗り
277sprite.fillRoundRect(x, y, w, h, r, color); // 角丸の矩形の塗り
250sprite.fillTriangle (x0, y0, x1, y1, x2, y2); // 3点間の三角形の塗り
292sprite.fillTriangle (x0, y0, x1, y1, x2, y2, color); // 3点間の三角形の塗り
1sprite.setColor     (color);         // 色指定

代表的な関数群の速度をためしてみました。こちらは描画するサイズによってかなり差がでるので、参考程度にしてください。

ざっくりした指針として、M5Stackの320×240の画面を秒間30フレームで描画する場合には、1フレームあたり33ミリ秒で描画する必要があります。オンメモリに320×240のスプライトは確保できないので320×80のスプライトを2枚確保して、交互に描画をしながら上、中、下と3回にわけて転送をします。320×80のDMA転送時間が約10ミリ秒なので、次の描画を10ミリ秒(10,000マイクロ秒)で準備できれば秒間30フレームで描画可能です。

例えばfillTriangle()の色指定無しが250マイクロ秒なので、320×240に40個ぐらい、画面全体で120個相当であれば10ミリ秒で間に合うかもしれません。fillCircle()だともっと早いのでたくさん描画できそうですね。ただこの時間は描画する大きさによってかなり変わるので、この計算通りには行かないと思います。

まとめ

かなり長々としてしまいましたが、Lcd.startWrite()とLcd.endWrite()は描画の前後で呼び出すようにしてください。スプライトや画像から数回の転送であってもDMA転送が使えるようになります。

DMA転送を利用する場合にはオンメモリのメモリである必要があります。PSRAMを利用した場合にはDMA転送ではなくなるため、バックグラウンド転送中に他の処理をすることはできません。秒間30フレーム必要でない場合には、PSRAMを使って画面サイズとおなじスプライトを作成して描画したほうが処理は楽になると思います。

DMA転送を利用して高速描画をする場合には、オンメモリにスプライトを確保する必要があります。ESP32で一度に確保できるサイズが100KB程度が上限のため、320×80などを2枚確保する必要があります。そして描画範囲を移動しながら3回描画をして画面全体を更新します。DMA転送をするため、ちょっと面倒な処理になるので注意が必要です。

スプライトの描画関数は、pushSprite()関数、pushPixels()関数、pushPixelsDMA()関数の3種類がありますが、通常はpushSprite()関数を使えば問題ないと思います。さらに高速動作させたい場合にはpushPixelsDMA()などにチャレンジしてみてください。

また、今回紹介した処理時間は一回のみの時間であり、誤差がかなり含まれると思います。とくにキャッシュの影響などで処理順番によって速度が変わることがあると思います。参考程度にして、実際の環境でいろいろ試しながらチューニングをしてみてください。

コメント

タイトルとURLをコピーしました