Цель статьи – объяснить разницу между CEF и WPE после года работы с этими фреймворками, предоставить инструкцию сборки и запуска полноценных JS+HTML+CSS веб-страниц с WPE на RaspberryPi 5 с zero-copy в 60+ FPS на FullHD. Посетовать, что такое нельзя сделать вместе с CEF. В конце мы будем иметь:
WPE для arm64 и amd64, OpenGL пайплайн вместе с EGL, работать всё это будет на встроенной системе Wayland. Wayland не должен никого пугать, на RaspberryPi 5 он идёт сразу в коробке, так что вы можете запускать и приложения на Wayland, и без перезагрузки приложения на X11. Но WPE zero-copy работает ТОЛЬКО с Wayland. Код будет представлен на языке С++.

Откуда такой вопрос?

Данный вопрос является актуальным, поскольку embedded разработка требует хорошо оптимизированный и удобный для расширения продукт. Мы хотим получать стабильные 60 фпс при показе полноэкранных FullHD веб-страниц с HTML и JavaScript, так же иметь при этом удобный пайплайн отрисовки, который будет использовать хардварные способы рендера, т.е. не опираться на SHM (shared memory через CPU) и отдавать кадр напрямую в видеокарту, так называемый zero-copy способ. В моём случае – я хочу использовать OpenGL пайплайн для микса разных видео-фреймворков на один экран в разные слои и через разные шейдеры.

Почему я отказался от CEF

Когда я приступил к своему проекту – а это довольно большое приложение для arm64/amd64 систем, на нём использовался фреймворк  OpenFrameworks как очень приятная обёртка над OpenGL, FFmpeg для рендера видео и CEF – как полноценный веб движок для рендера тяжёлых страниц с передачей данных из страницы в C++ код и обратно. Пока бизнес-план включал использование этого приложения на маленьких экранах – а-ля 400*600 проблем не было. CEF справлялся с рендером через CPU, и в принципе всё было хорошо. Объясню более подробно как рисует CEF на RaspberryPi 5.

На официальном сайте по адресу https://chromiumembedded.github.io/cef/general_usage.html в разделе Off-Screen Rendering мы можем найти небольшую инструкцию, как настроить CEF для рендера без создания окна. Т.е. мы можем создать класс браузера, который будет рисовать в нужный участок памяти CPU наш кадр, а мы уже сами будем брать его из этого участка и передавать в тот пайплайн, который мы используем. Это, конечно, хорошо, но мы должны понимать – что данные здесь передаются через CPU, причём два раза (а может даже три, тут мне так и не удалось выяснить). Первый раз – мы копируем эти данные из ссылки на массив, которая даётся в функции  OnPaint(), в свой массив. Это делается, потому что после вызова этой функции ссылка становится не действительной, т.е. мы не можем просто завязать этот участок памяти напрямую в текстуру. А второй раз мы копируем уже наш участок в GPU в текстуру OpenGL. Получается не очень оптимизированно, даже если сделать оптимизацию с помощью DirtyRects, которую предоставляет CEF. Т.е. мы можем делать memcpy только тех участков, которые действительно поменялись. Но как я выяснил – проблема даже не в двойной копии для CEF. Проблема в том, как долго сам CEF помещает картинку в этот массив, который потом отдаёт мне. Вот бенчмарк на копирование данных из функции OnPaint в мой массив байт для FullHD веб страниц (memcpy метод):

TEST: avg - 7067.17, max – 10046, count - 3078
TEST: avg - 7067.33, max – 10046, count - 3079
TEST: avg - 7067.37, max - 10046 ,count - 3080
TEST: avg - 7067.33 ,max – 10046, count - 3081
TEST: avg - 7067.55 ,max – 10046, count – 3082 

Здесь указаны значения в микросекундах.

Мы видим – что в среднем на один memcpy уходит 7 миллисекунд. Возьмём максимум – 10 миллисекунд, умножим на 2, так как этот массив надо передать в GPU – и получим 20 миллисекунд на кадр. Получается – RaspberryPi 5 позволяет через CPU делать 40-50 фпс. А вот скорость вызова самой функции OnPaint в CEF при разборе оказался 14 кадров в секунду. На экране всё это выглядело очень плохо. И поскольку эта функция вызывается из глубин CEF – повлиять на неё я тоже не могу.

Я решил основательно разобраться почему так происходит и как настроить CEF через текстуру, а не через CPU. Поискав на форуме – я нашёл упоминание функции OnAcceleratedPaint, которая передаёт ссылку на текстуру в GPU. К сожалению – в официальной документации данный метод не указан. Как его реализовывать – тоже не совсем понятно, поэтому я создал ветку на форуме и спросил как обстоит дело с OnAcceleratedPaint для RaspberryPi 5 (https://magpcss.org/ceforum/viewtopic.php?f=7&t=20360). Меня повели к коммитам по этой теме. Я всё изучил, однако у меня так и не получилось запустить хоть раз вызов OnAcceleratedPaint(). Может быть я криворукий, но я делал всё как написано в коммитах, даже больше. И чтобы я не делал – всегда вызывался метод рисования через CPU. Как я выяснил в исходниках CEF – метод OnAcceleratedPaint() вызывается только в случае получения картинки формата ARGB, в то время как на RPi5 вывод отдаётся в формате NV12. Если формат отличается от ARGB – то метод падает и вызывает просто OnPaint. Исходник: cef\libcef\browser\osr\video_consumer_osr.cc (строка 124). Так было и в 138 версии CEF, и в 140, до которой я обновился ради этих экспериментов.

Самым показательным методом будет запустить на своём устройстве:

sudo apt install libdrm-tests
modetest -p

Вывод нам покажет (по крайней мере у меня), что BROADCOM_SAND128 содержит NV12, NV21 и P030. А значит RPi5 будет декодировать кадр именно в этих форматах, что не поддерживают исходники CEF.

Так же можно запустить chromium и ввести в адрес chrome://gpu – там тоже будет много лога, который говорит что zero-copy на CEF в окружении RPi5 недоступен.

Учитывая всё вышеизложенное – я принял решение искать другой веб-движок.

WPE

Поискав по интернету – я нашёл несколько альтернатив – это Ultralight, WebKit и Qt WebEngine. Ultralight отпал, так как является платным, Qt отпал – поскольку у меня весь код был на чистом C++, поэтому приносить сюда тонну классов QT я не хотел. Поэтому выбор остановился на WebKit. Оказалось, что в интернете нет актуальных статей на тему того, как ставить WebKit на raspberry pi и как делать так, чтобы рендер был через zero-copy.Точнее вообще нет статей на эту тему. Есть вот эта статья: https://amandafalke.com/igalia/2023/01/17/building-wpe-webkit-for-raspberry-pi-3-tutorial, но она строится на создании образа линукса с уже готовыми библиотеками WebKit, и есть несколько репозиториев в GitHub, где WebKit используют таким же образом. Я не хотел делать свой линукс на Yokto или чём-то ещё, я хотел просто приложение, которое будет работать на любом линуксе просто как deb пакет. И у меня это получилось.

Первым делом надо скачать несколько библиотек:

·       wpewebkit-2.50.4 (https://wpewebkit.org/release/wpewebkit-2.50.4.html)

·       WPEBackend-fdo (https://github.com/Igalia/WPEBackend-fdo)

·       libwpe (https://github.com/WebPlatformForEmbedded/libwpe)

·       libbacktrace (https://github.com/ianlancetaylor/libbacktrace)

Я использовал именно эти версии, они были последними на момент конца 2025 года. Потом, нам конечно надо все эти библиотеки скомпилировать. Начинаем НЕ с WpeWebKit. Потому что она нуждается в других репозиториях. Компилируем остальные указанные библиотеки прямо на RPi5 (или кросс компилятором, я делал прямо на RPi5 для уверенности). После компиляции этих трёх библиотек мы начинаем компилировать WpeWebKit. Вы столкнётесь с тем, что оно напишет ошибки в нехватке библиотек. Ни в коем случае не отключайте их флагами – просто скачивайте все нужные библиотеки с github и компилируйте их, устанавливая, и заново запуская сборку WpeWebKit. Таких библиотеки будет много, но они все маленькие (libwoff, libavif и др). После того, как ошибок не будет – запускайте сборку wpewebkit-2.50.4 с параметрами -j1. Почему так – WpeWebkit очень большая библиотека – и собирается примерно СУТКИ. Это не шутка. Если вы запустите её на все ядра – RPi5 не хватит ресурсов и сборка упадёт. Я запускал сборку в один поток на ночь и шёл домой. Советую воспользоваться xterm для запуска sudo ninja -j1 по ssh, чтобы после отключения от ssh у вас RPi5 продолжала компилировать.

После успешной компиляции эту библиотеку надо установить. Собственно – на этом кончается самый основной этап – у нас есть среда выполнения WpeWebKit кода.

Теперь настало время перенести все наши библиотеки в наш sysroot, чтобы не компилировать нашу программу на RaspberryPi 5. Дальнейшая инструкция будет и в моём репозитории в конце статьи. Создаём папку, которая будет потом нашим SYS_ROOT. К примеру создаим папку root_fs как у меня и скопируем туда всё из нашей RPi5:

rsync -av --mkpath --no-perms user@rpi_ip:/usr/include ./root_fs/usr/
rsync -av --mkpath --no-perms user@rpi_ip:/usr/local/include ./root_fs/usr/local/
rsync -av --mkpath --no-perms user@rpi_ip:/usr/local/lib ./root_fs/usr/local/
rsync -av --mkpath --no-perms user@rpi_ip:/usr/lib ./root_fs/usr/
rsync -av --mkpath --no-perms user@rpi_ip:/usr/share ./root_fs/usr/
rsync -av --mkpath --no-perms user@rpi_ip:/lib ./root_fs/   

Примечание: возможно, какие-то библиотеки установятся на RaspberryPi 5 в кастомный путь, поэтому если у вас будут ошибки типа «cannot open .../libm.so: No such file or directory» - просто найдите эту библиотеку на RPi 5 и тоже так же перенести их с rsync. После этих команд надо поправить абсолютные пути в sysroot. Для этого в репозитории есть автоматический скрипт. Запуск:

./scripts/fix_sysroot_links.sh ./мой/путь/root_fs

Теперь нам надо установить кросскомпиляторы для arm64:

sudo apt install gcc-aarch64-linux-gnu g++-aarch64-linux-gnu

После этого CMake нужно направить на ваш root_fs. Для этого прописываем CMAKE_SYSROOT и устанавливаем правильные пути для pkg-config через переменные среды:

export PKG_CONFIG_SYSROOT_DIR="/абсолютный/путь/до/root_fs"
export PKG_CONFIG_LIBDIR="$PKG_CONFIG_SYSROOT_DIR/usr/local/lib/pkgconfig:$PKG_CONFIG_SYSROOT_DIR/usr/lib/aarch64-linux-gnu/pkgconfig:$PKG_CONFIG_SYSROOT_DIR/usr/share/pkgconfig"
unset PKG_CONFIG_PATH

 Всё, можем собирать:

mkdir build && cd build
cmake .. -DCMAKE_TOOLCHAIN_FILE=../cmake/rpi5_toolchain.cmake -DCMAKE_SYSROOT=/абсолютный/путь/до/root_fs
make 

В итоге получается бинарник, который работает в среде нашей RPi 5, где мы компилировали нашу Wpe. Чтобы его запустить – надо положить рядом с бинарником скрипт run.sh. Запускать его надо из-под root. Этот скрипт настроит среду Wayland и запустит ваше приложение. Если вы захотите запускать это приложение на RPi 5, где нет скомпилированных библиотек WPE – просто сделайте deb пакет, зашив внутрь все нужные библиотеки. Я делаю именно так – ничего в этом сложного нет, просто делаете, устанавливаете – если что-то не найдено – смотрите у себя в sysroot, если есть – добавляете эту библиотеку в deb-пакет. Если там этого нет – стягиваете с вашей родительской RPi5. И так пока всё не запустится. Да, к сожалению полностью предкомпилированной версии WPE для arm64 нет, но самое долгое – скомпилировать WPE. Остальное – уже рутина.

Компиляция для AMD64 и работа на WSL2

Тут шаги идентичны шагам для arm64. Просто собираете тот же самый комплект и всё. На родных машинах (не WSL) – работает так же в связке Wayland и нативной отрисовки с zero-copy. На WSL нет возможности напрямую получить доступ к видеокарте, поэтому в моём примере в репозитории есть fallback для SHM. Я сам так и тестирую – компилирую для amd64 на WSL2, запускаю, проверяю что всё хорошо отображается, и запускаю на нативных Linux машинах. Поэтому мой пример, собранный для amd64, будет работать и в нативной среде, и в WSL2 через обычную копию памяти CPU в текстуру.

Почему от OpenFrameworks тоже пришлось отказаться?

OpenFrameworks жёстко завязан на X11, и по вопросам на форуме к главному разработчику OF – рассматривать Wayland он в ближайшее время не будет. Поэтому мне пришлось убрать и весь OF из проекта, но это было не так сложно, поскольку OF, как я уже сказал - просто обёртка над OpenGL. Так что после создания нескольких маленьких своих обёрточек – код практически не поменялся.

Субъективное мнение о CEF и WPE

По итогу – мне в сотни раз больше понравилось работать с WPE. Главное причина – код в тысячу раз лаконичнее и проще для понимания. CEF требует своих строк CefString, постоянной имплементации своего подсчёта ссылок, сделать запуск нескольких процессов самостоятельно перед стартом приложения, проверять на правильность потоков внутри браузеров и так далее (класс браузера это по сути каждая новая вкладка у вас в Chrome). Так же очень не понравилось управление всеми механизмами через флаги, потом через строковые флаги к V8 JS движку, в общем – ужас. По ощущениям – слишком много специфичного кода на ровном месте, в то время как при работе с WPE вы просто создаёте класс страницы, настраиваете её через функции, а не через магические строки, и у вас всё работает.

Если говорить про механизмы получения информации от JS в С++ и обратно – в WPE реализация тоже является просто указанием на callback, который надо вызывать при получении сообщений от JS. В CEF же – надо имплементировать класс, который будет получать сообщения и отдавать обратно. Причём WPE имеет общий механизм отправки и приёма, в то время как CEF создаёт по умолчанию точку вызова своих функций – cefQuery. А WPE позволяет создавать всё что угодно, включая и cefQuery простыми функциями:

ucm_ = webkit_user_content_manager_new();
webkit_user_content_manager_register_script_message_handler(ucm_, "cefQuery", nullptr);
g_signal_connect(ucm_, "script-message-received::cefQuery", G_CALLBACK(onScriptMessage), this);

 onScriptMessage – это уже ваша функция с любой логикой.

А отправление сообщения обратно происходит так:

webkit_web_view_evaluate_javascript(WebKitWebView* ВАШ_ЭКЗЕМПЛЯР, std::string(“МОЯ строка”).c_str(), -1, nullptr, nullptr, nullptr, nullptr, nullptr);

Утечки памяти в CEF и WPE

Самая главная проблема как мы знаем по нашим браузерам – это потребление огромного количества оперативной памяти. Когда я работал с CEF – у нас была утечка на стороне браузерной части. Мы с командой проводили тесты и valgrind и AddressSanitizer, но на нашей стороне кода всё было хорошо. При отключении модуля браузера – утечки пропадали. Но в продакшене – раз в неделю приходилось перезапускать наше приложение, потому что даже при убийстве страниц и создания новых каждый раз – оперативная память медленно съедалась и не восстанавливалась. 8 гигабайт съедались за неделю.

На WPE я такого не заметил. У меня приложение работало 8 дней. В первые три дня оно отъело 1300 мегабайт, и на этом остановилось. Так что – видимо WPE гораздо более хорошо дружит с оптимизацией, чем CEF. Плюс там как и в CEF есть настройки по оптимизации, по размеру кэша и прочего, но по ощущением – CEF вообще не смотрит на это, в то время как WPE не раздувается.

Итог:

WPE – очень приятный и очень подходящий фреймворк для работы с полноценными JS+HTML+CSS страницами в среде embedded разработки. Единственный минус, который могу указать – скудная документация, из-за чего приходиться смотреть исходники. На сайте тоже просто список функций в алфавитном порядке с описанием. Из этого первый работающий пример сделать действительно сложно. Но можно 😊

Ссылка на репозиторий:

https://github.com/drakkonne007/WpeOnArm64AndAmd64

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
А вы каким стеком пользуетесь?
0%CEF0
33.33%WebKit1
66.67%QtWebEngine2
0%Ultralight0
Проголосовали 3 пользователя. Воздержался 1 пользователь.