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).

Qt, WebAssembly, QQuickFramebufferObject

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

As a result, among other things, you should get emsdk_env.sh script. It will set-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:

Simple C++ program as WASM

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:

WASM WebGL glTF textures

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.