やること
Raspberry Pi の上で Chromium(Chromium Embedded Framework)を動かし、ブラウザ画面を OLED に描画します。
構成・方針
Chromium を windowless モードで動かし、画面データを抜き取って、OLED display に直接投げます。
X や wayland は介さないです。
「 Chromium を windowless モードで動かし、画面データを抜き取る」が本記事のポイントです。パフォーマンスは落ちますが、これが出来ると大抵の事はお手軽にできるようになります。本記事では、この辺りを重点的に解説しているつもりです。
Chromium を全てソースコードからビルドしても良いのですが、空き容量や PC スペックが足りない方も多いと思います。ここでは、ブラウザを組み込むための Chromium Embedded Framework (以降 CEF と省略します) と、コアの部分がビルド済みになっているパッケージを利用します。
環境
クロスビルドは行わず、RaspberryPi 上でビルドします。
- RaspberryPi4 Raspberry Pi OS (buster)
- ubuntu 20.04 LTS
今回使用したビルド済みバージョンは、以下のバージョンのものです。
cef_binary_87.1.11+g8bb7705+chromium-87.0.4280.66
Chromium Embedded Framework を試す
入手
CEF の入手方法は、 repository(bitbucket) の wiki に手順が掲載されています。
https://bitbucket.org/chromiumembedded/cef/wiki/MasterBuildQuickStart.md
が、掲載されているのは、Chromium のソースコードからフルビルドを行う方法です。
This guide is NOT intended for:
Those seeking a prebuilt binary distribution for use in third-party apps. Go here instead.
を見ていくと、以下のサイトにたどり着くと思います。
https://cef-builds.spotifycdn.com/index.html
RaspberryPi 上で動作させるパッケージが欲しい場合は、 Linux ARM
の Standard Distribution
をダウンロードします1。
一旦手元で試したい…という場合は、 Linux 64-bit
の Standard Distribution
をダウンロードします。手元の環境が Linux で無い場合は、VirtualBox 等仮想環境を導入します。Windows/Mac は、本記事では触れません。
ビルド
cmakeを使います2。コマンドも一般的なC++プロジェクトと大差ありません。
sudo apt install cmake ninja-build
サンプルアプリは、cefclient, cefsimple 等複数入っています。 cefsimple をベースに色々手を加えていくので、 cefsimple をビルドすることにします。
mkdir -p out
cd out
cmake .. -GNinja
ninja cefsimple
生成物を実行してみて、Google が出たら ok です。
GUIが無い環境では起動できません。
# bash
./tests/cefsimple/Release/cefsimple
...
ちなみに、デフォルトではリリース版がビルドされます。
スレッドやnullptrを実行時にチェックするデバッグ版をビルドするには、cmakeコマンドを以下のようにします。
cmake .. -GNinja -DCMAKE_BUILD_TYPE=Debug
ninja cefsimple
./tests/cefsimple/Debug/cefsimple
arm版のバイナリパッケージを使う場合は、PROJECT_ARCH
マクロを追加します。これをしないと、-msse2
のような x86-64 アーキテクチャ固有のコンパイルオプションを付けられて、コンパイルが失敗します。
cmake .. -GNinja -DPROJECT_ARCH=arm
cefsimpleの各ファイルの解説
ざっくり説明します
- cefsimple_linux.cc
- main関数があります。CefAppを継承したSimpleAppの起動等を行っています。
- simple_app.cc
- SimpleAppの実装が含まれています。主にウィンドウを起動する実装が含まれています。
- ウィンドウの制御を行うクラス(開いた時や閉じた時の挙動を記述するSimpleWindowDelegate、新しくウィンドウを開く必要が起きた時の挙動を記述するSimpleBrowserViewDelegate)の定義・実装があります。
- simple_handler.cc
- ブラウザの画面やページのハンドラを定義・実装しています。
-
SimpleHandler
は、CefClient
と複数のCef*Handler
を継承します。 -
CefClient
は複数のCef*Handler
を管理するためのインターフェースです。- 例えば、
CefDisplayHandler
を実装するときは、CefClient::GetDisplayHandler()
をオーバーライドした、CefDisplayHandler
を継承したクラスのインスタンスの参照を返すメソッドを作成します。SimpleHandler
の実装例では、CefClient
とCefDisplayHandler
を多重継承しているので、return this;
しています。
- 例えば、
windowless モードで起動・画面データ取り出し
windowless モードで起動
windowless モード(headlessとも呼ばれていました)で起動するよう、コードを書き換えてみます。これにより、window なしでブラウザが起動します。
方法は簡単で、ブラウザの設定(CefSettings
)とブラウザウィンドウ(CefWindowInfo
)に windowless_rendering_enabled
というフラグがあるので、これらに true
を指定するだけです。
@@ -59,6 +59,7 @@ int main(int argc, char* argv[]) {
// Specify CEF global settings here.
CefSettings settings;
+ settings.windowless_rendering_enabled = true;
if (command_line->HasSwitch("enable-chrome-runtime")) {
// Enable experimental Chrome runtime. See issue #2969 for details.
@@ -123,6 +123,7 @@ void SimpleApp::OnContextInitialized() {
} else {
// Information used when creating the native window.
CefWindowInfo window_info;
+ window_info.windowless_rendering_enabled = true;
#if defined(OS_WIN)
// On Windows we need to specify certain flags that will be passed to
実行してみると、画面が表示されなくなりました。windowlessモードは効いているかもしれませんが、画面が無くなったので、これでは正しく動いているかどうか確認が取れません…。
ところで、上記の windowless モードにする方法ですが、公式ドキュメントを見つけられませんでした。
cefclient
の実装例を読むか、include
以下のヘッダファイル群を探し当てることになります3。
windowless モードで画面取得
実装の詳細は、Wikiに記載されています。windowless_rendering_enabled
については何故か書かれていません。
https://bitbucket.org/chromiumembedded/cef/wiki/GeneralUsage.md
Off-Screen Rendering の項目です。
windowless の状態で、どうやって操作するべきか?について書かれています。
The CefRenderHandler::OnPaint() method will be called to provide invalid regions and the updated pixel buffer. The cefclient application draws the buffer using OpenGL but your application can use whatever technique you prefer.
CefRenderHandler::OnPaint
で描画時に画素データを受け取れるようなので、やってみます。
追記する先は、CefClient
と複数の Cef*Handler
を多重継承している SimpleHandler
です。箇条書きで書くと、以下のステップになります。
-
SimpleHandler
に抽象クラスCefRenderHandler
を継承して実装する。 -
CefClient::GetRenderHandler()
をオーバーライドしたメソッドを作成する4。
SimpleHandler
に抽象クラス CefRenderHandler
を継承して実装する。
継承を追加
class SimpleHandler : public CefClient,
public CefDisplayHandler,
public CefLifeSpanHandler,
public CefLoadHandler,
public CefRenderHandler, { // << add
追加したクラスのメソッドを定義
// CefRenderHandler methods:
void GetViewRect(CefRefPtr<CefBrowser> browser, CefRect &rect) OVERRIDE;
void OnPaint(CefRefPtr<CefBrowser> browser, PaintElementType type,
const RectList &dirtyRects, const void *buffer, int width,
int height) OVERRIDE;
追加したメソッドの実装を書きます。今は特に実装しませんが、LOG(INFO)
で、ログを書き出せるので、書き出すようにします。
void SimpleHandler::GetViewRect(CefRefPtr<CefBrowser> browser, CefRect &rect) {
rect.width = 640;
rect.height = 480;
}
void SimpleHandler::OnPaint(CefRefPtr<CefBrowser> browser,
PaintElementType type, const RectList &dirtyRects,
const void *buffer, int width, int height) {
LOG(INFO) << "onpaint!";
}
CefClient::GetRenderHandler()
をオーバーライドしたメソッドを作成する。
CefRenderHandler
を実装したクラスのインスタンスは自身なので、this
を返します。
virtual CefRefPtr<CefRenderHandler> GetRenderHandler() OVERRIDE { return this; }
実行結果
[1116/013706.478396:INFO:simple_handler.cc(151)] onpaint!
[1116/013706.492529:INFO:simple_handler.cc(151)] onpaint!
[1116/013706.567228:INFO:simple_handler.cc(151)] onpaint!
[1116/013706.567908:INFO:simple_handler.cc(151)] onpaint!
[1116/013706.592335:INFO:simple_handler.cc(151)] onpaint!
描画したがっているのは分かりました。
OnPaint から画面データを取り出す
状況が分からないので、受け取ったデータをファイルか何かに書き出してみます。
include/cef_render_handler.h によると、
// Called when an element should be painted. Pixel values passed to this
// method are scaled relative to view coordinates based on the value of
// CefScreenInfo.device_scale_factor returned from GetScreenInfo. |type|
// indicates whether the element is the view or the popup widget. |buffer|
// contains the pixel data for the whole image. |dirtyRects| contains the set
// of rectangles in pixel coordinates that need to be repainted. |buffer| will
// be |width|*|height|*4 bytes in size and represents a BGRA image with an
// upper-left origin. This method is only called when
// CefWindowInfo::shared_texture_enabled is set to false.
buffer
は |width|*|height|*4 bytes
で、BGRA
の順で画素データが並べられているようです。
適当に以下のように記述して出力してみます。
#include <fstream>
void SimpleHandler::OnPaint(CefRefPtr<CefBrowser> browser,
PaintElementType type, const RectList &dirtyRects,
const void *buffer, int width, int height) {
static int counter = 0;
std::string filename = std::to_string(counter++) + ".bgra";
LOG(INFO) << "onpaint: " << filename << " (" << width << "x" << height << ")";
std::ofstream o(filename, std::ios_base::out);
o.write((char *)buffer, width * height * 4);
o.close();
// note: BAD な実装です。
}
画像ファイルが出力されたと思います。画像データを確認するには、 image magick で png に変換するのが楽です。
$ convert -depth 8 -size 640x480 bgra:./20.bgra out.png
画面が得られました。カーソルの点滅が原因で、画像を出力し続けていたんですね。
OnPaint から「別スレッドで」画面データを取り出す
先ほど書いたコードは誤りがあります。ファイルの書き込み等の重い処理をUIスレッドで行うべきではないからです。
Chromium を含む多くの GUI アプリケーションは、複数のスレッドで動作します。特に、UI スレッド(メインスレッドとも呼ばれることがある)は、ユーザ入力を受ける処理を行うので、このスレッドに負荷がかかると、操作が利かなくなる等、不幸なことが起こりえます。
UI 以外のスレッドについては、以下に記載されています。
https://bitbucket.org/chromiumembedded/cef/wiki/GeneralUsage.md#markdown-header-threads
自前でスレッドを作成、管理する場合は、CefThread
、 CefThread#GetTaskRunner
を使います。
CEF には FILE スレッドがあります。このスレッドに処理を投函する (CefPostTask
) ことにします5。
#include <fstream>
#include <vector>
#include "include/base/cef_bind.h"
#include "include/wrapper/cef_closure_task.h"
namespace {
void WriteData(std::string filename, std::vector<char> buffer, int length) {
// debug ビルド時 FILE スレッド以外から呼ばれたら SIGABRT する
// release ビルド時はコンパイルしない
DCHECK(CefCurrentlyOn(TID_FILE));
std::ofstream o(filename, std::ios_base::out);
o.write(buffer.data(), length);
o.close();
}
}
void SimpleHandler::OnPaint(CefRefPtr<CefBrowser> browser,
PaintElementType type, const RectList &dirtyRects,
const void *buffer, int width, int height) {
static int counter = 0;
DCHECK(CefCurrentlyOn(TID_UI));
std::string filename = std::to_string(counter++) + ".bgra";
int length = width * height * 4;
std::vector<char> copied_buffer(length);
std::copy((const char*)buffer, (const char*)buffer + length, copied_buffer.begin());
LOG(INFO) << "onpaint: " << filename << " (" << width << "x" << height << ")";
CefPostTask(TID_FILE, base::Bind(&WriteData, filename, std::move(copied_buffer), width*height*4));
}
- WriteData はファイルに書き込むだけの関数です。
- SimpleHandler のインスタンスの参照は要らないので、メソッドにはしていません。
- 他のファイルから参照しないので、無名名前空間で囲みます
-
OnPaint
で渡される*buffer
を非同期関数の引数に渡すと危険です。非同期関数が実行される頃にはdelete
されているかどうか分からない為です。バッファをコピーしておき、データごと渡すようにします。 -
CefPostTask
の後にcopied_buffer
は使わないので、std::move
します。std::move
しないと、引数に渡すためのデータのコピーが発生するため無駄です。
取り出した画像データを OLED に描画する
ここから先は OLED 依存なので短めに書きます。
以下の SPI 通信で描画出来る 128x128 の OLED を使いました。
128x128, General 1.5inch RGB OLED display Module, 16-bit high color
https://www.waveshare.com/1.5inch-rgb-oled-module.htm
OLED にとにかく描画する
wiki にサンプルコードがあるので、これで動作確認しました。ライセンスはMIT(ただしフォントは3条項BSD)です。
- 圧縮ファイルを展開すると、
RasberryPi/c
ディレクトリがあると思います。このディレクトリを使います - makefile を開き、 WiringPi を使うよう変更します。デフォルトでは bcm2835.h を使うようになっていますが、手元の動作確認では SIGSEGV で死にました。恐らくバージョン違い。
- OLED から RPi0 への接続は、VCC -> 5V, GND -> GND, DIN -> SPI0 MOSI, CLK ->SPI0 SCLK, CS -> GPIO24, DC -> GPIO25, RST -> GPIO8 です。これらはサンプルコードから読み取れます。
- RPi0 のピン配置は https://pinout.xyz/pinout/spi を参照しました。
make
でビルドして、得られた実行ファイルを実行すると、以下のようにデモが動きました。
サンプルコードを外部ライブラリ化する
手を抜きます。main 関数周辺の実装と、makefile だけを書き換えて外部ライブラリ化させてみます。
main.c を以下のように書き換えてみます。(RGBOLED_displaySampleBitmap はテスト用に残しておきました)
#include "OLED_Driver.h"
#include "OLED_GFX.h"
void RGBOLED_init() {
DEV_ModuleInit();
Device_Init();
}
void RGBOLED_displayBitmap(void* data, uint16_t size) {
uint16_t i = 0;
if (size > 128*128*2)
size = 128*128*2;
RAM_Address();
Write_Command(0x5C);
for( ; i < 128*128*2; i+=2) {
color_byte[0] = ((uint8_t*)data)[i];
color_byte[1] = ((uint8_t*)data)[i+1];
Write_Data(color_byte[0]);
Write_Data(color_byte[1]);
}
}
void RGBOLED_displaySampleBitmap(void) {
uint16_t i = 0;
RAM_Address();
Write_Command(0x5C);
for( ; i < 128*128*2; i+=2) {
color_byte[0] = gImage_bmp2[i];
color_byte[1] = gImage_bmp2[i+1];
Write_Data(color_byte[0]);
Write_Data(color_byte[1]);
}
}
makefile は以下のように書き換えました。主に shared library を作るように引数の変更と、出力ファイル名の変更です。
# TARGET = main
TARGET = oled.so
### 省略 ###
# ${TARGET}:${OBJ_O}
# $(CC) $(CFLAGS) $(OBJ_O) -o $@ $(LIB)
${TARGET}:${OBJ_O}
$(CC) -shared -Wl,-soname,$(TARGET) $(CFLAGS) $(OBJ_O) -o $@ $(LIB)
make すると、oled.so
という名前の共有ライブラリが得られます。
作成した shared の動作確認
CEF とは別に、共有ライブラリを実行時に読み込んで関数を呼び出すコードを書いてみます。C++ではなく、C言語です。
共有ライブラリを実行時に開くには、dlfcn の関数を使います。
また、ライブラリ dl をリンクする必要があります
#include <dlfcn.h>
#define NULL 0
void *handle;
void (*RGBOLED_init)(void);
void (*RGBOLED_displaySampleBitmap)(void);
int main() {
handle = dlopen("./oled.so", RTLD_LAZY);
if (handle == NULL) return 1;
RGBOLED_init = (void (*)(void))dlsym(handle, "RGBOLED_init");
RGBOLED_displaySampleBitmap = (void (*)(void))dlsym(handle, "RGBOLED_displaySampleBitmap");
if (RGBOLED_init == NULL) return 1;
if (RGBOLED_displaySampleBitmap == NULL) return 1;
RGBOLED_init();
RGBOLED_displaySampleBitmap();
dlclose(handle);
return 0;
}
上のソースコードのファイル名を test.c とすると、gccを使ったビルドコマンドは、以下のようになります。
gcc test.c -ldl -o test
oled.so を生成された実行ファイルと同じディレクトリの下にコピーします。
生成された実行ファイル test
を実行すると、サンプルコードに含まれていた風景画が OLED に映し出されます。
CEF に oled のモジュールを組み込む
「作成した shared の動作確認」で書いた実装をCEFのプロジェクトに組み込みます。
OLED を制御するためのクラスを作る
C++ のクラスで再実装します。
インスタンスは唯一で十分なので、シングルトンパターンで実装します。wikipediaを参考にしています。
以下は定義部分のヘッダファイル oled_driver.h
です。
#ifndef OLED_DRIVER_H_
#define OLED_DRIVER_H_
class OLEDDriver {
public:
static OLEDDriver& getInstance();
void initialize();
void displayBitmap(void* data, int width, int height);
private:
OLEDDriver();
~OLEDDriver();
OLEDDriver& operator=(const OLEDDriver&) = delete;
OLEDDriver(OLEDDriver&&) = delete;
OLEDDriver& operator=(OLEDDriver&&) = delete;
};
#endif // OLED_DRIVER_H_
次に実装部分のコードです。ファイル名は oled_driver.cc
としました。
OLEDとブラウザ画面の画素データの間で色順序やビット幅が異なるので、変換実装を書いています。
#include "tests/cefsimple/oled_driver.h"
#include <dlfcn.h>
#include <cstdint>
#include "include/base/cef_logging.h"
namespace {
void *handle = nullptr;
void (*RGBOLED_init)(void);
void (*RGBOLED_displayBitmap)(void*, uint16_t);
}
OLEDDriver::OLEDDriver() {
handle = dlopen("./oled.so", RTLD_LAZY);
if (handle == nullptr) {
LOG(ERROR) << __FUNCTION__ << " dlopen failed";
return;
}
RGBOLED_init = (void (*)(void))dlsym(handle, "RGBOLED_init");
RGBOLED_displayBitmap = (void (*)(void*, uint16_t))dlsym(handle, "RGBOLED_displayBitmap");
if (RGBOLED_init == nullptr) {
LOG(ERROR) << __FUNCTION__ << " dlsym RGBOLED_init failed";
dlclose(handle);
handle = nullptr;
return;
}
if (RGBOLED_displayBitmap == nullptr) {
LOG(ERROR) << __FUNCTION__ << " dlsym RGBOLED_displayBitmap failed";
dlclose(handle);
handle = nullptr;
return;
}
}
OLEDDriver::~OLEDDriver() {
if (handle == nullptr)
return;
dlclose(handle);
handle = nullptr;
}
OLEDDriver& OLEDDriver::getInstance() {
static OLEDDriver instance;
return instance;
}
void OLEDDriver::initialize() {
if (handle == nullptr)
return;
RGBOLED_init();
}
void OLEDDriver::displayBitmap(void* data, int width, int height) {
if (handle == nullptr)
return;
// 対象のOLEDは15ビットカラー(に設定されている)で、渡される画素データはalpha値含む32ビットカラー
// 変換する必要がある。
uint16_t buffer[128 * 128];
std::fill(buffer, buffer + 128 * 128, (uint16_t)0xFFFF);
for (int y = 0; y < std::min(height, 128); ++y) {
for (int x = 0; x < std::min(width, 128); ++x) {
uint32_t color32 = ((uint32_t *)data)[y * width + x];
uint16_t b = ((color32)&0xFF) >> 3; // 8bit -> 5bit
uint16_t g = ((color32 >> 8) & 0xFF) >> 3; // 8bit -> 5bit
uint16_t r = ((color32 >> 16) & 0xFF) >> 3; // 8bit -> 5bit
// blue 5bit, red 5bit, green 5bit
buffer[y * 128 + x] = (b << 10) | (r << 5) | g;
}
}
RGBOLED_displayBitmap(buffer, 128 * 128 * 2);
}
作成したクラスを組み込む
SimpleHandler への追加
simple_handler.cc
に書いた、ファイルに画像を書き出す実装を、OLED に書き出す実装に置き換えます。
全く重要では無いですが、解像度を渡す引数を増やしています。
void WriteData(std::string filename, std::vector<char> buffer, int length, int width, int height) {
// std::ofstream o(filename, std::ios_base::out);
// o.write(buffer.data(), length);
// o.close();
OLEDDriver::getInstance().displayBitmap((void*)buffer.data(), width, height);
LOG(INFO) << "wrote: " << filename;
}
void SimpleHandler::OnPaint(CefRefPtr<CefBrowser> browser,
PaintElementType type, const RectList &dirtyRects,
const void *buffer, int width, int height) {
// ... 省略 ...
CefPostTask(TID_FILE, base::Bind(&WriteData, filename, std::move(copied_buffer), width*height*4, width, height));
}
また、OLED の画面解像度は 128x128 なので、SimpleHandler::GetViewRect
もその画面解像度を返すようにします。
void SimpleHandler::GetViewRect(CefRefPtr<CefBrowser> browser, CefRect &rect) {
rect.width = 128;
rect.height = 128;
}
CMakefile へ追加
以下のように、追加するファイルと、shared library の動的ロードをするためのライブラリ dl の追加が必要です。
diff --git a/tests/cefsimple/CMakeLists.txt b/tests/cefsimple/CMakeLists.txt
index 1cc6477..30e3293 100644
--- a/tests/cefsimple/CMakeLists.txt
+++ b/tests/cefsimple/CMakeLists.txt
@@ -8,6 +8,8 @@
# cefsimple sources.
set(CEFSIMPLE_SRCS
+ oled_driver.cc
+ oled_driver.h
simple_app.cc
simple_app.h
simple_handler.cc
@@ -89,7 +91,7 @@ if(OS_LINUX)
add_executable(${CEF_TARGET} ${CEFSIMPLE_SRCS})
SET_EXECUTABLE_TARGET_PROPERTIES(${CEF_TARGET})
add_dependencies(${CEF_TARGET} libcef_dll_wrapper)
- target_link_libraries(${CEF_TARGET} libcef_lib libcef_dll_wrapper ${CEF_STANDARD_LIBS})
+ target_link_libraries(${CEF_TARGET} libcef_lib libcef_dll_wrapper ${CEF_STANDARD_LIBS} dl)
# Set rpath so that libraries can be placed next to the executable.
set_target_properties(${CEF_TARGET} PROPERTIES INSTALL_RPATH "$ORIGIN"
動作確認
wikipedia を表示させてみました。top画と同じ画像です。
画面が小さくて何かに使うには厳しいですかね…
何故wikipediaなのかというと、googleの場合、以下のようになってしまうからです。これは面白くない
残課題
- 描画が遅い
- サンプルコードをそのまま突っ込んでいるので、無駄は多いです
- それでもSPI通信がボトルネックになっていて、遅いです
- アニメーションを含む画面の場合は、描画が追い付かなくなると思います。フレームレートを設定するCEFのAPIを使ったり、詰まっていたら描画処理を捨てたりする実装が必要です
- x window system / GUI からの起動が必要
- 外せるはず
- include 以下のディレクトリに
#define CEF_X11 1
を定義している箇所があるので、0
にする -
CMakefiles.txt
から x11 関連を削除する
- include 以下のディレクトリに
- 外せるはず
- raspberrypi zero で動かせてない
-
Illegal Instruction
と出て動かせませんでした。chromiumソースコードからのフルビルドが必要かも
-
終わりに
ACCESS Advent Calendar 2020 の 8 日目の記事でした。最後まで目を通していただきありがとうございます。
ここまでボリュームを増やすつもりは無かったのですが、画面を取り出すところで止めておけばよかったですかね…