Qt for WebAssembly and custom OpenGL via QQuickFramebufferObject
It is amazing what kind of crazy ideas people might come up with. One of our users decided that they want to use our visualization engine inside their Qt application on Windows and Linux (so far so good) and also to build a version for WebAssembly to target web-browsers (fucking hell).
Very surprisingly to me, this actually works!
Environment
I’ve tried it on Mac OS, but quite possibly everything will build fine on other platforms too. My environment:
$ sw_vers -productVersion
11.5.2
$ clang --version
Homebrew clang version 12.0.1
Target: x86_64-apple-darwin20.6.0
Thread model: posix
InstalledDir: /usr/local/opt/llvm/bin
$ python --version
Python 3.9.6
$ cmake --version
cmake version 3.21.1
About Qt for WebAssembly
What is WebAssembly? In my silly understanding, it’s a format of executable programs for web-browsers, like .exe
for Windows, kind of? In other words, you can compile your C++ (or other) sources using special compiler, such as Emscripten, into .wasm
file, and that file can be then executed by a web-browser.
WebAssembly is a standard, and almost all modern web-browsers support it. So basically browsers are turning into universal cross-platform launchers (aw jeez).
And yes, Qt has a (limited) support for WebAssembly, so you can (probably) compile your Qt-based application sources into WebAssembly - it all depends on what Qt modules you are using in your application.
Here is the current list of supported modules. That documentation page also has other useful information and links, such as platform notes, blog posts (this one conveniently has a list of bugreports), wiki and live demos.
In addition to the documentation I would also recommend to watch this video-recording from Qt World Summit 2019.
It wouldn’t hurt to mention that Qt for WebAssembly is licensed under either commercial license or GPLv3, so no LGPL option. I am not quite sure how would that affect users projects, as actual Qt libraries have LGPL option (not all of them, but still), however I’ll leave that to legal specialists.
And as I understood, currently it only works with qmake
.
Installing Emscripten
To compile something to WebAssembly, you need a compiler. Emscripten is mentioned everywhere, and I don’t know if there is actually any other.
It is important to use the right version of Emscripten (so wow, much stability). For Qt 5.15.x you need to use version 1.39.8
, and here’s a list of matches for other versions.
Install Emscripten and activate it with the right version:
$ cd /path/to/programs
$ git clone --depth 1 git@github.com:emscripten-core/emsdk.git
$ cd ./emsdk
$ ./emsdk list
$ ./emsdk install 1.39.8
$ ./emsdk activate 1.39.8
$ ./emsdk construct_env
If trying to run emsdk
you get an error like this:
Fatal Python error: init_sys_streams: can't initialize sys standard streams
Python runtime state: core initialized
Traceback (most recent call last):
File "/path/to/python/Lib/io.py", line 54, in <module>
ImportError: cannot import name 'text_encoding' from 'io' (unknown location)
or no output in stdout/stderr, but still an error exit code, then it might be that something is wrong with the Emscripten’s bundled Python, and so try to use your system Python instead, like this:
$ which python
/path/to/python
$ python --version
Python 3.9.6
$ EMSDK_PYTHON=python ./emsdk list
or like this:
$ export EMSDK_PYTHON=python
$ ./emsdk list
Anyway, as a result, among other things, you should get emsdk_env.sh
script for setting-up the build environment:
$ source ./emsdk_env.sh
$ em++ --version
Don’t forget that this environment will apply only to the current shell session, so even if you just open a new tab in your terminal, this script needs to be sourced again.
Test a simple C++ program
To check that Emscripten works, make a simple C++ program (some.cpp
):
#include <iostream>
int main(int argc, char *argv[])
{
std::cout << "ololo" << std::endl;
return EXIT_SUCCESS;
}
Test that it at least compiles with a C++ compiler:
$ clang++ ./some.cpp -o some
$ ./some
ololo
And then try to compile it with Emscripten:
$ em++ ./some.cpp -o some.html
That will build your program and generate the following:
some.wasm
some.html
some.js
If you are curious enough, you can take a look at the code in some.js
, to get an idea about how all that is supposed to work, but it has over 5000 lines of JS code, so you probably won’t.
If you now open some.html
in a web-browser, you’ll get a web-page with a canvas, a textarea and an error in browser console about loading some.wasm
. In Chromium it will be this:
Fetch API cannot load file:///path/to/project/some.wasm. URL scheme "file" is not supported.
In Firefox it will be slightly different, but essentially the same error:
Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at file:///path/to/project/some.wasm. (Reason: CORS request not http).
Modern browser don’t like when you try to read files from disk. To get pass this restriction, you can launch your browser via emrun:
$ emrun --browser ~/Applications/Firefox\ Developer\ Edition.app/Contents/MacOS/firefox ./some.html
It seems to be trying to bind to 0.0.0.0
, so if you don’t want to create yet another firewall rule for yet another thing in your system, you can host some.html
with a proper web-server instead. For testing I usually prefer a simple Python script like this one:
import http.server
import socketserver
class HttpRequestHandler(http.server.SimpleHTTPRequestHandler):
extensions_map = {
".html" : "text/html",
".css" : "text/css",
".js" : "application/javascript",
".json" : "application/json",
".wasm" : "application/wasm",
".png" : "image/png",
".jpg" : "image/jpg",
".svg" : "image/svg+xml",
".xml" : "application/xml",
"" : "application/octet-stream"
}
host = "localhost"
port = 8000
httpd = socketserver.TCPServer((host, port), HttpRequestHandler)
try:
print(f"serving at http://{host}:{port}")
httpd.serve_forever()
except KeyboardInterrupt:
pass
Note the MIME types for JS and WASM. You will probably need to set them in other web-servers too (at least I needed to do so in NGINX). Speaking about setting things, in production environment, if using pthreads, you most likely will also need to set COOP and COEP HTTP headers.
Also, one of the Qt developers/maintainers of Qt for WebAssembly provided his own, more advanced web-server implementation (also in Python). This one even supports HTTPS.
Either way, you can run the script-server like this:
$ python ./server.py
Or, if not using any scripts:
$ python -m http.server 8000
And then open /some.html
:
What sorcery is this, a C++ programs runs inside a web-browser! How bizarre!
Building Qt SDK for WebAssembly
If you can get pre-built Qt for WebAssembly binaries, probably that would be the easiest, otherwise you’ll need to build it from sources. I took version 5.15.2.
Unpack and configure the build:
$ cd /path/to/qt/sources
$ mkdir build && cd $_
$ ../configure -static -release -no-pch -xplatform wasm-emscripten -no-feature-thread -prefix "/Users/vasya/programs/qt/5152-wasm" -skip qtwebengine -nomake tools -nomake tests -nomake examples
That will configure Qt to build without thread/pthreads support (-no-feature-thread
). It’s better this way for finding possible problems in your application, and when it’s all good, then you can re-build Qt with threads enabled. Also, not every browser might actually support pthreads, so that could be another reason for building Qt without it. Finally, should you decide to build with theads, note that Emscripten also needs to have support for pthreads enabled.
Now you can build Qt:
$ time make -j12 module-qtbase module-qtdeclarative
This way it will build only the specified modules. I only needed these ones, but if you need everything, then you can just run make
without listing modules.
Anyway, having started the build, I got this error after a while, so the build failed:
/bin/sh: /Users/vasya/programs/emsdk/python: is a directory
Can’t say what it was trying to do, but I just simply ran make
again, and the rest of the build went fine. It took about 20 minutes (both attempts combined).
Then you can ran the installation:
$ time make -j12 install
And that took unusually a lot of time - 15 minutes - comparing to a couple of minutes when building for “regular” platforms. The installed Qt build resulted in 198 MB.
Building a Qt application for WebAssembly
Check that you still have Emscripten environment set and that qmake
is working:
$ em++ --version
emcc (Emscripten gcc/clang-like replacement) 1.39.8
$ ~/programs/qt/5152-wasm/bin/qmake --version
QMake version 3.1
Using Qt version 5.15.2 in /Users/vasya/programs/qt/5152-wasm/lib
First, let’s take something simple, for example the glorious Qt Quick application of mine - Color Corners - and try to build it with Qt for WebAssembly:
$ git clone git@github.com:retifrav/color-corners.git
$ cd color-corners
$ mkdir build && cd $_
$ ~/programs/qt/5152-wasm/bin/qmake "CONFIG+=release" ../color-corners.pro
If you did not set the Emscripten environment, then you’ll get:
Project ERROR: Cannot run target compiler 'em++'. Output:
===================
===================
Maybe you forgot to setup the environment?
Once qmake
succeeds, try to build the project:
$ make -j12
Most likely that will fail with the following errors:
...
error: undefined symbol: _ZN3JSC4Yarr12digitsCreateEv
warning: Link with `-s LLD_REPORT_UNDEFINED` to get more information on undefined symbols
warning: To disable errors for undefined symbols use `-s ERROR_ON_UNDEFINED_SYMBOLS=0`
error: undefined symbol: _ZN3JSC4Yarr12spacesCreateEv
error: undefined symbol: _ZN3JSC4Yarr13newlineCreateEv
error: undefined symbol: _ZN3JSC4Yarr14wordcharCreateEv
error: undefined symbol: _ZN3JSC4Yarr15nondigitsCreateEv
error: undefined symbol: _ZN3JSC4Yarr15nonspacesCreateEv
error: undefined symbol: _ZN3JSC4Yarr17nonwordcharCreateEv
error: undefined symbol: _ZN3JSC4Yarr31wordUnicodeIgnoreCaseCharCreateEv
error: undefined symbol: _ZN3JSC4Yarr34nonwordUnicodeIgnoreCaseCharCreateEv
Error: Aborting compilation due to previous errors
shared:ERROR: '/Users/vasya/programs/emsdk/node/12.18.1_64bit/bin/node /Users/vasya/programs/emsdk/upstream/emscripten/src/compiler.js /var/folders/5x/jyhk_09s1m53tt41l55xtgmw0000gn/T/tmp411g4ctz.txt' failed (1)
make: *** [color-corners.js] Error 1
To set these mysterious flags you need to edit LFLAGS
string in the Makefile
(produced by qmake
). For instance, let’s add -s LLD_REPORT_UNDEFINED
to the end of the line:
LFLAGS = -s WASM=1 -s FULL_ES2=1 -s FULL_ES3=1 -s USE_WEBGL2=1 -s EXIT_RUNTIME=1 -s ERROR_ON_UNDEFINED_SYMBOLS=1 -s EXTRA_EXPORTED_RUNTIME_METHODS=["UTF16ToString","stringToUTF16"] --bind -s FETCH=1 -O2 -s ALLOW_MEMORY_GROWTH=1 -s LLD_REPORT_UNDEFINED
Having run make
again, I got a more detailed but still useless output:
wasm-ld: error: symbol exported via --export not found: _get_environ
shared:ERROR: '/Users/vasya/programs/emsdk/upstream/bin/wasm-ld -o /var/folders/5x/jyhk_09s1m53tt41l55xtgmw0000gn/T/emscripten_temp_ixovwtae/color-corners.wasm --lto-O0 main.o color-corners.js_plugin_import.o -L/Users/vasya/programs/emsdk/upstream/emscripten/system/local/lib color-corners.js_qml_plugin_import.o -L/Users/vasya/programs/emsdk/upstream/emscripten/system/lib qrc_qml.o -L/Users/vasya/programs/emsdk/upstream/emscripten/cache/wasm-obj /Users/vasya/programs/qt/5152-wasm/plugins/platforms/libqwasm.a /Users/vasya/programs/qt/5152-wasm/lib/libQt5EventDispatcherSupport.a /Users/vasya/programs/qt/5152-wasm/lib/libQt5FontDatabaseSupport.a /Users/vasya/programs/qt/5152-wasm/lib/libqtfreetype.a /Users/vasya/programs/qt/5152-wasm/lib/libQt5EglSupport.a /Users/vasya/programs/qt/5152-wasm/plugins/imageformats/libqgif.a /Users/vasya/programs/qt/5152-wasm/plugins/imageformats/libqicns.a /Users/vasya/programs/qt/5152-wasm/plugins/imageformats/libqico.a /Users/vasya/programs/qt/5152-wasm/plugins/imageformats/libqjpeg.a /Users/vasya/programs/qt/5152-wasm/plugins/imageformats/libqtga.a /Users/vasya/programs/qt/5152-wasm/plugins/imageformats/libqtiff.a /Users/vasya/programs/qt/5152-wasm/plugins/imageformats/libqwbmp.a /Users/vasya/programs/qt/5152-wasm/plugins/imageformats/libqwebp.a /Users/vasya/programs/qt/5152-wasm/qml/QtQuick.2/libqtquick2plugin.a /Users/vasya/programs/qt/5152-wasm/qml/QtQuick/Window.2/libwindowplugin.a /Users/vasya/programs/qt/5152-wasm/qml/QtQuick/Layouts/libqquicklayoutsplugin.a /Users/vasya/programs/qt/5152-wasm/lib/libQt5Quick.a /Users/vasya/programs/qt/5152-wasm/lib/libQt5Gui.a /Users/vasya/programs/qt/5152-wasm/lib/libqtlibpng.a /Users/vasya/programs/qt/5152-wasm/lib/libqtharfbuzz.a /Users/vasya/programs/qt/5152-wasm/qml/QtQml/libqmlplugin.a /Users/vasya/programs/qt/5152-wasm/qml/QtQml/Models.2/libmodelsplugin.a /Users/vasya/programs/qt/5152-wasm/lib/libQt5QmlModels.a /Users/vasya/programs/qt/5152-wasm/lib/libQt5Qml.a /Users/vasya/programs/qt/5152-wasm/lib/libQt5Network.a /Users/vasya/programs/qt/5152-wasm/lib/libQt5Core.a /Users/vasya/programs/qt/5152-wasm/lib/libqtpcre2.a /Users/vasya/programs/emsdk/upstream/emscripten/cache/wasm-obj/libc.a /Users/vasya/programs/emsdk/upstream/emscripten/cache/wasm-obj/libcompiler_rt.a /Users/vasya/programs/emsdk/upstream/emscripten/cache/wasm-obj/libc-wasm.a /Users/vasya/programs/emsdk/upstream/emscripten/cache/wasm-obj/libc++-noexcept.a /Users/vasya/programs/emsdk/upstream/emscripten/cache/wasm-obj/libc++abi-noexcept.a --whole-archive /Users/vasya/programs/emsdk/upstream/emscripten/cache/wasm-obj/libembind-rtti.a /Users/vasya/programs/emsdk/upstream/emscripten/cache/wasm-obj/libfetch.a --no-whole-archive /Users/vasya/programs/emsdk/upstream/emscripten/cache/wasm-obj/libgl-webgl2-full_es3.a /Users/vasya/programs/emsdk/upstream/emscripten/cache/wasm-obj/libdlmalloc.a /Users/vasya/programs/emsdk/upstream/emscripten/cache/wasm-obj/libpthread_stub.a /Users/vasya/programs/emsdk/upstream/emscripten/cache/wasm-obj/libc_rt_wasm.a /Users/vasya/programs/emsdk/upstream/emscripten/cache/wasm-obj/libsockets.a --allow-undefined-file=/var/folders/5x/jyhk_09s1m53tt41l55xtgmw0000gn/T/tmpf1nlw_dn.undefined --import-memory --import-table -mllvm -combiner-global-alias-analysis=false -mllvm -enable-emscripten-sjlj -mllvm -disable-lsr --strip-debug --export __wasm_call_ctors --export __data_end --export main --export __errno_location --export fflush --export strstr --export _get_environ --export _get_tzname --export _get_daylight --export _get_timezone --export htonl --export strlen -z stack-size=5242880 --initial-memory=16777216 --no-entry --global-base=1024' failed (1)
make: *** [color-corners.js] Error 1
What did help is to set -s ERROR_ON_UNDEFINED_SYMBOLS=0
instead. Then the build went fine, as those errors became warnings (and so these symbols aren’t actually needed?):
$ make -j12
sed -e s/@APPNAME@/color-corners/g /Users/vasya/programs/qt/5152-wasm/plugins/platforms/wasm_shell.html > /Users/vasya/code/qt/color-corners/build/color-corners.html
cp -f /Users/vasya/programs/qt/5152-wasm/plugins/platforms/qtloader.js /Users/vasya/code/qt/color-corners/build
cp -f /Users/vasya/programs/qt/5152-wasm/plugins/platforms/qtlogo.svg /Users/vasya/code/qt/color-corners/build
em++ -s WASM=1 -s FULL_ES2=1 -s FULL_ES3=1 -s USE_WEBGL2=1 -s EXIT_RUNTIME=1 -s ERROR_ON_UNDEFINED_SYMBOLS=1 -s EXTRA_EXPORTED_RUNTIME_METHODS=["UTF16ToString","stringToUTF16"] --bind -s FETCH=1 -O2 -s ALLOW_MEMORY_GROWTH=1 -s ERROR_ON_UNDEFINED_SYMBOLS=0 -o ./color-corners.js main.o color-corners.js_plugin_import.o color-corners.js_qml_plugin_import.o qrc_qml.o /Users/vasya/programs/qt/5152-wasm/plugins/platforms/libqwasm.a /Users/vasya/programs/qt/5152-wasm/lib/libQt5EventDispatcherSupport.a /Users/vasya/programs/qt/5152-wasm/lib/libQt5FontDatabaseSupport.a /Users/vasya/programs/qt/5152-wasm/lib/libqtfreetype.a /Users/vasya/programs/qt/5152-wasm/lib/libQt5EglSupport.a /Users/vasya/programs/qt/5152-wasm/plugins/imageformats/libqgif.a /Users/vasya/programs/qt/5152-wasm/plugins/imageformats/libqicns.a /Users/vasya/programs/qt/5152-wasm/plugins/imageformats/libqico.a /Users/vasya/programs/qt/5152-wasm/plugins/imageformats/libqjpeg.a /Users/vasya/programs/qt/5152-wasm/plugins/imageformats/libqtga.a /Users/vasya/programs/qt/5152-wasm/plugins/imageformats/libqtiff.a /Users/vasya/programs/qt/5152-wasm/plugins/imageformats/libqwbmp.a /Users/vasya/programs/qt/5152-wasm/plugins/imageformats/libqwebp.a /Users/vasya/programs/qt/5152-wasm/qml/QtQuick.2/libqtquick2plugin.a /Users/vasya/programs/qt/5152-wasm/qml/QtQuick/Window.2/libwindowplugin.a /Users/vasya/programs/qt/5152-wasm/qml/QtQuick/Layouts/libqquicklayoutsplugin.a /Users/vasya/programs/qt/5152-wasm/lib/libQt5Quick.a /Users/vasya/programs/qt/5152-wasm/lib/libQt5Gui.a /Users/vasya/programs/qt/5152-wasm/lib/libqtlibpng.a /Users/vasya/programs/qt/5152-wasm/lib/libqtharfbuzz.a /Users/vasya/programs/qt/5152-wasm/qml/QtQml/libqmlplugin.a /Users/vasya/programs/qt/5152-wasm/qml/QtQml/Models.2/libmodelsplugin.a /Users/vasya/programs/qt/5152-wasm/lib/libQt5QmlModels.a /Users/vasya/programs/qt/5152-wasm/lib/libQt5Qml.a /Users/vasya/programs/qt/5152-wasm/lib/libQt5Network.a /Users/vasya/programs/qt/5152-wasm/lib/libQt5Core.a /Users/vasya/programs/qt/5152-wasm/lib/libqtpcre2.a
warning: undefined symbol: _ZN3JSC4Yarr12digitsCreateEv
warning: undefined symbol: _ZN3JSC4Yarr12spacesCreateEv
warning: undefined symbol: _ZN3JSC4Yarr13newlineCreateEv
warning: undefined symbol: _ZN3JSC4Yarr14wordcharCreateEv
warning: undefined symbol: _ZN3JSC4Yarr15nondigitsCreateEv
warning: undefined symbol: _ZN3JSC4Yarr15nonspacesCreateEv
warning: undefined symbol: _ZN3JSC4Yarr17nonwordcharCreateEv
warning: undefined symbol: _ZN3JSC4Yarr31wordUnicodeIgnoreCaseCharCreateEv
warning: undefined symbol: _ZN3JSC4Yarr34nonwordUnicodeIgnoreCaseCharCreateEv
So, what have we built:
$ ls -l | awk '{print $9 " | " $5}'
Makefile | 56K
color-corners.html | 3.2K
color-corners.js | 327K
color-corners.js_plugin_import.cpp | 449B
color-corners.js_plugin_import.o | 1.2K
color-corners.js_qml_plugin_import.cpp | 308B
color-corners.js_qml_plugin_import.o | 943B
color-corners.wasm | 16M
main.o | 1.3K
qrc_qml.cpp | 5.3K
qrc_qml.o | 1.8K
qtloader.js | 21K
qtlogo.svg | 3.0K
Host that with a web-server and open /color-corners.html
:
If video doesn’t play in your browser, you can download it here.
Omg, it really works!
Custom OpenGL via QQuickFramebufferObject
As I said in the beginning, it all started with a request from a user, who wanted to run our visualization engine in a Qt Quick application inside a browser as WebAssembly.
They would be okay with Qt Widgets too, but QOpenGLWidget does not work in WebAssembly yet (reported more than 3 years ago), so the other alternative is QQuickFramebufferObject, which means a Qt Quick application.
Well, actually, looking at this blog post, it might be still possible to have a Qt Widgets application, which would have a QQuickWidget, which would contain a QQuickFramebufferObject, which would be a host for your custom OpenGL, but I haven’t researched that possibility yet. Although, looking at this bugreport, it seems to be coming down to QOpenGLWidget again, so likely that approach won’t work either.
But anyway, Qt Quick application will do just fine. I took one of the samples that we have for Qt Quick and built it in the same manner as Color Corners application above.
It built fine, but when I tried to run it in a browser, it had the Qt Quick UI controls, but the scene/canvas was empty (although with a right clear color), and there was this error in the browser console in Firefox:
Loading Worker from “http://localhost:8000/worker.js” was blocked because of a disallowed MIME type (“text/html”).
or simply this one in Chromium:
GET http://localhost:8000/worker.js 404 (File not found)
That’s because I forgot to copy our engine’s JS runtime (worker.js
) to the build folder. After I did that, I got a different error in Firefox:
RuntimeError: abort(CompileError: wasm validation error: at offset 4: failed to match magic number) at jsStackTrace@http://localhost:8000/worker.js
or this one in Chromium:
Uncaught (in promise) RuntimeError: abort(CompileError: WebAssembly.instantiate(): expected magic word 00 61 73 6d, found 3c 21 44 4f @+0)
That, as I figured from a similar issue, was because I also forgot to copy the actual WebAssembly build of our engine, and so application tried to fetch it and got 404 error from the web-server, which was an HTML document starting with <!DOCTYPE HTML...
, and so 3c21 444f
is exactly this sequence of symbols: <!DO
. And if you open any .wasm
file in a HEX viewer, you’ll see, that it starts with 0061 736d
. So I copied the missing WASM to the build folder too and reloaded the page:
If video doesn’t play in your browser, you can download it here.
Holly shit, that works too! So it’s our OpenGL-enabled visualization engine, written in C++, rendering in OpenGL context, which is created by QQuickFramebufferObject, part of a Qt Quick application, compiled to WebAssembly and running inside Mozilla Firefox web-browser. Fuck me.
Drawbacks
While the current state of things is quite exciting already, not everything is quite awesome yet.
Looking at the last video, you might’ve noticed that VLC cones are all black. That is wrong, because actually they should have textures. Here’s a screenshot of the same scene in a simpler application without Qt - bare HTML5 canvas and WebGL, also compiled to WebAssembly and running in the same browser:
Another problem I noticed is that applying certain visual effects, such as ambient occlusion, results in the scene simply being filled with solid black color, so apparently there are some issues with shaders or whatnot. And so it’s quite likely that other issues like that will be discovered going forward. For the record, the very same Qt application built for desktop target doesn’t have any of these problems.
So I can’t say that I would recommend Qt for WebAssembly as a target platform to our users at the moment. But like I said, all the issues I’ve stumbled upon so far were related to rendering in QQuickFramebufferObject, which definitely is not a very common use-case.
Overall, it’s really fascinating that the whole thing works at all, and with a decent performance too. I honestly did not expect much, except for maybe lots of troubles building and setting things up, but even that worked just fine almost out of the box.
What I would recommend to everyone else is to try building your Qt-based application for WebAssembly and see for yourself how it works.
Social networks
Zuck: Just ask
Zuck: I have over 4,000 emails, pictures, addresses, SNS
smb: What? How'd you manage that one?
Zuck: People just submitted it.
Zuck: I don't know why.
Zuck: They "trust me"
Zuck: Dumb fucks