Nativer Code im Web mit Webassembly
Innerhalb der letzten zwei Jahrzehnte hat sich das Web zunehmend von seinem ursprünglichen Zweck entfernt und sich von der Bereitstellung von verlinkten und überwiegend statischen Inhalten zu einer Plattform zur Ausführung von Anwendungen gewandelt. Ermöglicht wurde dies unter anderem durch die Einführung von Javascript. Ursprünglich konzipiert, um Webseiten interaktiver zu gestalten, hat Javascript trotz seiner vielen Eigenarten die Welt erobert.
Als Brendan Eich vor einigen Jahren unter Zeitdruck die erste Version von Javascript entwickelte, war ihm vermutlich nicht klar, dass Entwickler die Nutzung seiner Sprache so weit vorantreiben würden. Am Erfolg der Sprache nicht ganz unbeteiligt war unter anderem die Einführung von Googles V8 Engine. Diese führte zu einer deutlich gesteigerten Performance, wodurch in Javascript erstmals Dinge möglich wurden, die vorher unmöglich schienen. Die Weiterentwicklung der Sprache selbst sowie die Einführung neuer Webstandards machte schließlich auch die Entwicklung großer Applikationen praktikabel, die im Browser laufen. Dennoch gibt es einige Anwendungsfälle, die von Javascript nicht abgedeckt werden und daher bisher im Browser nur eingeschränkt Verwendung fanden. Dazu zählt die Ausführung von High Performance Code aber auch bestehender C/C++ Code.
Um diese Lücke zu schließen, entwickelte Mozilla 2013 asm.js und Emscripten. Asm.js ist ein Subset von Javascript, das es einem Browser ermöglicht,
Code besser und stärker zu optimieren. Beispielsweise werden in asm.js Code keine Javascript-Objekte allokiert, sondern es wird ausschließlich ein großes „Typed Array“ als Speicher genutzt. Der Speicher innerhalb dieses Arrays muss manuell verwaltet werden. Das Wegfallen der Garbage-Collection macht den Code zwar nicht in allen Situation schneller, aber die Ausführungszeit wird dadurch in der Regel deterministischer.
Gedacht war asm.js als Ziel von Compilern wie beispielsweise das auf LLVM bzw. Clang basierte Emscripten, das C/C++ Code für das Web kompiliert. Asm.js benötigt keine spezielle Browser-Unterstützung. Jeder ES6 kompatible Browser kann den Code ausführen. Bei direkter Unterstützung kann die Javascript-Engine den Code aber viel aggressiver in Maschinencode umwandeln als generischen Javascript-Code. Die Ausführung des Codes ist somit deutlich effizienter.
Ein Problem, das asm.js jedoch nicht löst, ist das Eliminieren des erheblichen Overheads, der beim Parsen einer Programmiersprache wie Javascript entsteht. Beim Schreiben von Scripten ist das dynamische Parsen und Übersetzen zur Laufzeit ein Kompromiss, den man gerne eingeht, um das Ergebnis von Code-Änderungen schneller und effektiver betrachten zu können. Ist der auszuführende Code das Resultat eines vorangegangenen Compiliervorgangs, ist dieser Vorgang allerdings unnötig ineffizient.
Ein Lösungsansatz wäre, direkt Maschinencode einer bestimmten CPU-Architektur in eine Webseite einzubetten und auszuführen. Diesen Ansatz verfolgte Google in Chrome/Chromium mit seiner NativeClient (NaCl) Technik. Dabei kamen Prozessorarchitektur-spezifische ausführbare Module (nexe) zum Einsatz, die sicher in einer Sandbox ausgeführt werden konnten. NaCl Applikationen kommunizierten ähnlich wie z. B. das Flash-Plugin über Chromes Pepper API mit dem Browser. Durch die relativ leichtgewichtige Sandbox-Technik können Native Programme mit sehr geringen Performance-Einbußen ausgeführt werden. Ein Nachteil der Technik ist allerdings, dass sie sich nicht gut mit den Ideen des Webs verträgt. Zum einen müssen Binaries für verschiedene Prozessorarchitekturen zur Verfügung gestellt werden und zum anderen ist Googles Pepper API Chrome spezifisch und schwer in andere Browser zu implementieren.
Außerdem benötigt NaCls Sandboxtechnik Prozessorarchitektur-spezifische Hacks, die sich nicht gut verallgemeinern lassen und die Verwendung bestimmter Architekturen erschweren oder sogar komplett ausschließen. Das Problem wurde durch die Einführung von Portable Native Client teilweise entschärft, aber nicht vollständig gelöst. PNaCl Module verwenden CPU-unabhängigen LLVM Bitcode, der erst vom Browser in CPU-spezifischen Maschinencode übersetzt wird.
Da hier der Browser selbst den auszuführenden Code erzeugt und dieser daher die volle Kontrolle hat, ist (anders als bei Native Client) keine komplexe Sandboxtechnik notwendig. PNaCl beruht immer noch auf der Pepper Plugin-Schnittstelle und wurde wie NaCl nie wirklich ausführlich spezifiziert. Daher wurde es von keinem anderen Browserhersteller akzeptiert und implementiert.
Mit Webassembly einigten sich Google, Mozilla, Microsoft und Apple 2015 schließlich auf einen gemeinsamen Webstandard, der asm.js und PNaCl ablöst. Im Unterschied zu den beiden Vorgängern kommt dabei eine stack-basierte Virtuelle Maschine zum Einsatz. Webassembly Programme werden, ähnlich wie bei PNaCl, in einem eigenen binären Format (wasm) übertragen. Daneben gibt es noch ein für Menschen lesbares Textformat, das Webassembly Code hierarchisch mithilfe von „S-expressions“ darstellt. Dank des binären Formats ist Webassembly deutlich kompakter als asm.js Code und dazu noch wesentlich effizienter und schneller zu parsen.
Webassembly wird in den gängigen Browsern „Ahead Of Time“ zu möglichst effizienten Maschinencode kompiliert. Die Performance ist zum gegenwärtigen Zeitpunkt durchschnittlich um 50% langsamer als der selbe Code, der nativ ausgeführt wird.
Für diesen Blogbeitrag wollte ich die Möglichkeiten, die durch Webassembly geboten werden, testen und ausprobieren, ob es für die Praxis bereits einsatzbereit ist. Dazu habe ich mehrere Sprachen und Toolchains getestet. Mein Fokus lag dabei bei der Portierung eines bereits bestehenden kleinen C „Wochenendprojekts“, mit dem ich vor ein paar Jahren begonnen hatte. Dabei handelt es sich um einen Klon der Gameboy-Version eines sehr bekannten Spiels dessen Namen ich hier nicht nennen brauche ;-). Der Code wurde ursprünglich mit dem Ziel geschrieben, ohne größere Modifikationen auf möglichst vielen unterschiedlichen Plattformen lauffähig zu sein, weshalb ich auf C anstatt C++ setzte, da es für fast jede Plattform einen mehr oder weniger brauchbaren C Compiler gibt. Die Plattformen, die mir dabei in den Sinn kamen, waren beispielsweise ein 8-Bit AVR Microcontroller, an dem ein kleines Farb LCD angeschlossen ist, sowie meine alte Playstation 2. Der Code sollte dabei als eine Art „Hello World“-Programm fungieren, mit dem ich die Plattformen erkunde. Das Projekt schien mir sehr passend für diesen Blogbeitrag.
Auswahl der Toolchain
Zum gegenwärtigen Zeitpunkt ist der auf LLVM basierte Clang meines Wissens der einzige C/C++ Compiler, mit dem Webassembly erzeugt werden kann. Wenn man die entsprechenden Compiler-Optionen setzt, funktioniert das mit aktuellen Versionen sogar out of the box. Das Problem besteht allerdings darin, dass keine Webassembly-kompatible libc mit dabei ist und man somit entweder ohne auskommen muss oder eine eigene zur Verfügung stellen muss. APIs, die typischerweise von einem Betriebssystem zur Verfügung gestellt werden, wie I/O Operationen oder das Vergrößern des verwendbaren Speichers (sbrk, mmap usw… ) müssen bei der Ausführung von Webassembly innerhalb einer Browserumgebung durch Aufrufen von Javascript Code implementiert werden. Da man dies in vielen Fällen nicht selbst erledigen möchte, empfiehlt sich die Verwendung von Emscripten oder des neueren Wasi-SDK.
Emscripten ist eine komplette Toolchain, die ursprünglich für asm.js entwickelt wurde, aber mittlerweile Webassembly-Code erzeugen kann. Enthalten sind sowohl LLVM mit Clang als auch eine libc Implementierung sowie APIs, um (eingeschränkt) direkt mit html, WebGL, Browserevents und Javascript zu interagieren. Da Webassembly auch für den Einsatz außerhalb von Browsern interessant ist, gibt es mittlerweile WASI (Webassembly System Interface), die eine einheitliche Posix-basierte API für den Einsatz innerhalb und außerhalb von Browsern zur Verfügung stellt. Das Wasi-SDK enthält ähnlich wie Emscripten neben Clang eine libc Implementierung, aber keine Browser-spezifischen APIs. Für die Portierung eines Spiels war Emscripten deshalb meine bevorzugte Wahl.
Installation
Wenn nicht schon vorhanden, muss Python 3 installiert werden. Unter MacOS ist Python bereits im Emscripten SDK enthalten.
Am einfachsten ist es, sich das Emscripten SDK direkt von Github zu ziehen und lokal in einem Ordner im Heimverzeichnis zu installieren. Der genaue Vorgang kann unter Windows abweichen.
git clone https://github.com/emscripten-core/emsdk.git cd emsdk ./emsdk install latest ./emsdk activate latest
Vor jeder Verwendung müssen noch Umgebungsvariablen gesetzt werden. Dazu inkludiert man emsdk_env.sh.
source ./emsdk_env.sh
Verwendung
Emscripten liefert wrapper Scripte für die gängigen C/C++ Entwicklertools.
Code wird mit `emcc` compiliert. Wer mit gcc- oder clang-Parametern vertraut ist, wird sich sich wie Zuhause fühlen.
Abhängig davon, mit welcher Endung man die Ausgabedatei angibt, werden unterschiedliche Dateien erzeugt.
emcc ... -o output.html
Wird als Ausgabe eine Datei mit Endung .html angegeben, erzeugt der Compiler neben Webassembly sowohl eine html als auch eine Javascript-Datei, die Code enthält, der dazu dient, Webassembly in eine Webseite zu integrieren. Dazu zählt das Laden der wasm Datei sowie Funktionen, die von Emscriptens libc genutzt werden: z. B. um bei Bedarf den nutzbaren Heap Speicher zu vergrößern (sbrk). Enthalten sind aber auch Funktionen, die dabei helfen, in C/C++ geschriebene Eventhandler zu registrieren und einiges mehr.
Die automatisch erzeugte html-Datei enthält ein Canvas-Element, in das von Emscripten aus gerendert werden kann, aber auch jede Menge UI-Aspekte, das während der Entwicklung hilfreich sein kann.
emcc ... -o output.js
Endet die Ausgabedatei auf .js, wird nur Webassembly + Javascript Code erzeugt, der in eine eigene Seite eingebettet werden kann. Es empfiehlt sich, diese Vorlage als Grundlage für das Einbetten zu nehmen. Alternativ kann man sich auch wie zuvor beschrieben von Emscripten html erzeugen lassen und mit --shell-file
seine eigene Vorlage angeben.
emcc ... -o output.wasm
Wird eine .wasm Datei angegeben, wird ausschließlich Webassembly-Code im Standalone-Modus erzeugt. Da ich diese Option selbst nicht verwendet habe, kann ich dazu nichts sagen.
Neben emcc enthält das SDK auch Scripte, die dabei helfen, Emscripten zusammen mit Build-Systemen wie CMake oder Autotools zu verwenden.
Im Fall von CMake heißt das Script emcmake und wird vor den eigentlichen CMake Befehl geschrieben. Dabei wird ganz transparent eine Toolchain-Datei angegeben, mit deren Hilfe CMake automatisch die korrekten Emscripten-spezifischen Tools findet und nutzt. Außerdem werden noch einige nützliche CMake-Variablen und Präprozessor-Makros gesetzt, die es ermöglichen, die Verwendung von Emscripten zu erkennen und spezifische Codepfade zu implementieren.
emcmake cmake ...
Portierung
Da der Code mit der Absicht geschrieben wurde, auf möglichst vielen sehr unterschiedlichen Plattformen lauffähig zu sein, war es sehr einfach, den Code zumindest rudimentär lauffähig zu bekommen. Aufwendig dagegen war es, das Spiel "optimal" an die Gegebenheiten eines Browsers anzupassen. Neben der Tatsache, dass sich eine Webseite dynamisch an die Seitenverhältnisse und Größen unterschiedlicher Displays anpassen sollte, waren da auch noch die unterschiedlichen vorhandenen Eingabemethoden der Nutzer. Ursprünglich hatte ich nicht bedacht, dass auch Nutzer mobiler Geräte wie Smartphones oder Tablets die Seite besuchen könnten. Beim Spieldesign bin ich nämlich davon ausgegangen, dass die Steuerung über externe Geräte wie Tastatur, Gamepad oder Taster erfolgen würde. Touchscreens waren mir gar nicht in den Sinn gekommen. Deshalb musste ich nachträglich ein Touchscreen-Interface hinzufügen. Das Spiel setzt auf "Pixel Art" und eine fixe 160x144px Auflösung. Deshalb habe ich das Canvas mittels CSS mit Nearest Neighbor Interpolation auf unterschiedliche Display-Größen skaliert.
Main Loop und Events
Ein Hauptbestandteil eines jeden Spiels ist der sogenannte Gameloop. Dabei handelt es sich klassischerweise um einen Mainloop, bei dem zuerst die Eingaben ermittelt werden, dann die Spielwelt upgedatet und schließlich das Resultat geändert und ausgegeben wird. In Multithreaded-Spielen ist es oft etwas komplexer. Ein wesentlicher Unterschied zwischen nativen und im Browser laufenden Spielen ist der, dass man den Mainloop des Browsers nutzen muss. Dazu registriert man Callback-Funktionen, die vom Browser Mainloop aufgerufen werden. Emscripten ermöglicht es, auf alle gängigen Events reagieren.
Zum Einlesen der Eingaben nutzt man die emscripten_set_XXXX_callback Funktionen. Dabei hat man die Möglichkeit, der Callbackfunktion einen Parameter zu übergeben. In unseren Fall das "Game" Objekt, in dem der aktuelle State des Spieles gespeichert ist.
emscripten_set_keydown_callback(EMSCRIPTEN_EVENT_TARGET_WINDOW, game, 1, keyDownCallback); emscripten_set_keyup_callback(EMSCRIPTEN_EVENT_TARGET_WINDOW, game, 1, keyUpCallback); emscripten_set_mousedown_callback("#canvas", game, 1, mouseCallback); emscripten_set_mouseup_callback("#canvas", game, 1, mouseCallback); emscripten_set_touchstart_callback("#canvas", game, 1, touchStartCallback); emscripten_set_touchend_callback("#canvas", game, 1, touchEndCallback);
Die Callback-Funktion liefert uns alle Informationen, die wir brauchen um das Event zu verarbeiten. Mithilfe des Rückgabewertes teilen wir dem Browser mit, ob wir ein Event verarbeitet oder ignoriert haben.
EM_BOOL keyDownCallback(int eventType, const EmscriptenKeyboardEvent* e, void* userData);
Für das Ausführen der Spielelogik und das Rendern haben wir mehrere Möglichkeiten. Wir können den Code entweder mithilfe eines Timers in einem fixen Zeitintervall ausführen oder mithilfe von `emscripten_set_main_loop()`. Letzteres ist empfehlenswert, wenn man rendert, da die hinterlegte Callback-Funktion genau zu dem Zeitpunkt, an dem der Browser selbst rendert, aufgerufen wird und mit der Bildwiederholrate synchronisiert wird. Man muss dann aber selbst dafür sorgen, dass die Update-Geschwindigkeit der Spielwelt unabhängig von der Framerate des Browsers ist.
void mainLoopCallback(void* arg) { struct Game* game = (struct Game*)arg; gameUpdate(game); gameDraw(game); drawTouchUi(); render(render_buffer, sizeof(render_buffer), CANVAS_WIDTH, CANVAS_HEIGHT); }
Canvas
In Emscripten ist eine Portierung der weit verbreiteten SDL-Bibliothek enthalten. In meiner nativen PC-Fassung des Spiels nutze ich SDL2 zum Einlesen der Eingaben und zum Anzeigen des Render-Puffers. Da Emscriptens integrierte Version auf SDL1 basiert, dessen API sich teilweise deutlich von SDL2 unterscheidet und ich kein Interesse hatte, meinen Code auf eine alte, nicht Browser-native API zu portieren und gleichzeitig mehr über Emscriptens Browser-Integration lernen wollte, habe ich auf die Verwendung von SDL zu verzichten. Emscriptens html5 API bietet Funktionen an, die es einem ermöglichen, einen WebGL Kontext zu erzeugen und damit in ein Canvas-Element zu rendern. Da mein Spiel aber mithilfe eigener, sehr simpler Routinen in einen sehr kleinen 160x144px Buffer zeichnen, würde es sich nicht lohnen, für die Darstellung dieses Puffers in einem Canvas-Element WebGl zu nutzen, geschweige denn die 8x8 Pixel Tiles selbst mithilfe von WebGl zu zeichnen. Der einzige Grund, der meiner Ansicht nach die Verwendung von WebGl rechtfertigen würde, wäre die Verwendung eines Fragmentshaders, um die Ästhetik eines originalen Gameboy LCDs zu emulieren. Die simpelste Methode, um mithilfe eines Canvas Elements und ohne WebGL einen bereits bestehenden Puffer anzuzeigen, ist die Verwendung von putImageData()
. Leider bietet Emscripten mit Ausnahme der Erzeugung eines WebGl Kontextes keinerlei Hilfsfunktionen, um auf das Canvas-Element zuzugreifen. Die einzige Möglichkeit, die mir blieb, war das Schreiben einer kleinen Javascript Wrapper-Funktion. Glücklicherweise bietet Emscripten Hilfsmakros an, die das direkte Einbetten und Aufrufen von Javascript in C Code sehr einfach gestalten.
EM_JS(void, render, (const uint32_t* ptr, size_t buffer_size, int canvas_width, int canvas_height), { const buff = Module.HEAPU8.subarray(ptr, ptr + (buffer_size)); const canvas = document.getElementById('canvas'); const ctx = canvas.getContext('2d'); const image = ctx.getImageData(0, 0, canvas_width, canvas_height); image.data.set(buff); ctx.putImageData(image, 0, 0); })
Javascript kann direkt auf den Webassembly-Speicher in Form von TypedArray Views zugreifen. In diesen Fall nutzt man Module.HEAPU8, um einzelne Bytes adressieren zu können. Mithilfe von subarray()
und der Adresse unseres Puffers bekommt man ein Uint8Array, das unsere Pixeldaten enthält. Dieses Array kann anschließend als „Image Data“ für unser Canvas genutzt werden.
Verwendung innerhalb eigenem HTML oder in Angular
Die Verwendung von Emscripten generiertem Webassembly innerhalb einer Angular Componente erwies sich als schwieriger als erwartet.
Eine Problem war unter anderem, den von Emscripten generierten Javascript „Glue code“ zu importieren. Innerhalb des Typescript Codes eines Components habe ich es jedenfalls nicht geschafft. Das könnte aber auch an Angulars Verwendung von Webpack liegen. Die direkte Einbindung von Javascript mittels des `
Demo
Das Spiel kann hier im Vollbild getestet werden. Alternativ könnt ihr auch die unten eingebundene Version testen, bei der aber keine Tastatureingabe verfügbar ist. Den Code sowie das Test -Angular-Projekt findet ihr auf unserer Github Seite.