Petite introduction à WebAssembly

2023-01-21

Couverture de l'article

Pour les besoins d'un projet nécessitant du traitement d'image, je me suis récemment penché sur WebAssembly, une technologie permettant d'apporter des performances supérieures à celle de JavaScript dans les navigateurs.

Dans cet article, je vais vous présenter ce qu'est exactement WebAssembly et comment ça s'utilise au travers de différents exemples couvrant les principaux points qui m'ont été utiles lors de la réalisation de mon projet.

Ces exemples mêleront du code JavaScript et du code C, mais pas de panique, on ne va pas faire de choses trop compliquées et je vais tout vous expliquer en détail ! 😁️

Contents

WebAssembly ? Qu'est-ce que c'est exactement ?

WebAssembly, abrégé WASM, est un standard du W3C définissant un format de bytecode bas niveau, compact, sûr (il tourne dans une sandbox), portable (pas dépendant d'une architecture ou d'un microprocesseur), et pouvant s'exécuter dans les navigateurs Web (entre autres).

WebAssembly n'est donc pas un langage de programmation, mais une target de compilation. Il est possible de compiler un programme écrit dans un langage de plus ou moins haut niveau comme C, C++, Rust ou encore Go vers WebAssembly, de la même manière qu'on l'aurait compilé pour Linux / amd64 ou pour Windows / x86.

Illustration de WebAssembly comme target de compilation

WebAssembly nous fournit également des API pour faire le lien entre le code ainsi compilé et le code JavaScript d'une page Web.

Les principaux intérêts de WebAssembly sont :

Un point important à considérer avant de se lancer, c'est la compatibilité. Il serait en effet dommage d'écrire une super application en WebAssembly si elle ne peut tourner nulle part... Eh bien soyez rassurés, on est pas mal de ce côté-là : les principaux navigateurs de bureau (Firefox, Chromium, Chrome, Safari, Opera et Edge) le supportent depuis 2017, et les navigateurs mobiles ne sont pas en reste.

Vous pourrez retrouver toutes les informations de compatibilité sur caniuse.com/wasm.

caniuse.com/wasm

Compatibilité de WebAssembly sur les navigateurs de bureau

Maintenant que vous en savez un peu plus à son sujet, passons un peu à la pratique pour voir comment ça s'utilise.

Premier programme WebAssembly : Hello World

Commençons donc avec un traditionnel « Hello world » afin de se faire la main.

Installation du compilateur

Étant donné que l'on va devoir compiler un programme C vers WebAssembly, on va devoir installer un compilateur qui, dans notre cas, sera Emscripten.

Pour Linux, un paquet est disponible sur la plupart des distributions. Par exemple, il est possible de l'installer sous Ubuntu (à partir de la 22.04) à l'aide de la commande suivante :

sudo apt install emscripten

Si vous utilisez macOS, il y a un paquet Homebrew. Pour Windows, le plus simple est soit de passer par WSL afin d'utiliser le paquet pour Linux, soit de passer par Chocolatey pour une installation native. Pour les autres systèmes ou si vous souhaitez utiliser d'autres méthodes d'installation, vous pouvez consulter la documentation officielle d'Emscripten.

Homebrew

Chocolatey

documentation officielle d'Emscripten

Écriture de notre programme

Passons maintenant à l'écriture de notre programme. Comme je l'ai dit en introduction, nous allons coder les parties WebAssembly en C ; on va donc commencer par créer un fichier nommé "hello.c" et y placer le code suivant :

#include <stdio.h>

int main() {
    puts("Hello World");
    return 0;
}

Compilation du programme

Si on voulait compiler ce programme de manière classique pour notre système, on pourrait le faire avec la commande suivante :

gcc hello.c -o hello

Eh bien pour le compiler pour WebAssembly, la commande n'est pas très différente :

emcc hello.c -o hello.html

Une fois compilé, on se retrouve avec les trois fichiers suivants :

Lancer l'application dans le navigateur

Si vous ouvrez le fichier "hello.html" directement avec votre navigateur, vous vous apercevrez rapidement que rien ne fonctionne. Si vous avez la curiosité d'ouvrir la console JavaScript, vous pourrez y voir une erreur similaire à celle-ci :

Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at file:///.../hello.wasm. (Reason: CORS request not http).
failed to asynchronously prepare wasm: [object ProgressEvent]
warning: Loading from a file URI (file:///.../hello.wasm) is not supported in most browsers. See https://emscripten.org/docs/getting_started/FAQ.html#how-do-i-run-a-local-webserver-for-testing-why-does-my-program-stall-in-downloading-or-preparing

https://emscripten.org/docs/getting_started/FAQ.html#how-do-i-run-a-local-webserver-for-testing-why-does-my-program-stall-in-downloading-or-preparing

Capture d'écran de l'erreur dans le navigateur

Le script de "hello.js" essaye en effet de télécharger "hello.wasm" mais il ne peut bien évidemment pas accéder aux fichiers sur le disque dur pour des raisons de sécurité.

Pour résoudre ce problème, il va falloir servir les fichiers à l'aide d'un serveur HTTP. Le plus simple pour le moment c'est d'ouvrir une console dans le dossier contenant nos fichiers puis de lancer le serveur HTTP intégré à Python :

cd dossier/de/mon/projet/
python3 -m http.server

On peut ensuite se rendre à l'adresse "http://localhost:8000/hello.html" à l'aide de notre navigateur :

http://localhost:8000/hello.html

Capture d'écran de l'application WebAssembly tournant dans le navigateur.

Et cette fois-ci ça fonctionne ! On peut constater que notre « Hello World » s'affiche bien, à la fois dans la « console » Emscripten présente dans la page ainsi que dans la console JavaScript.

Plus d'options de compilation

Il existe une option de compilation qui peut nous éviter d'avoir à lancer un serveur HTTP : -sSINGLE_FILE. Cette option fait en sorte que tout se retrouve dans un unique fichier et résout donc nos problèmes de téléchargement lorsque l'on ouvre la page HTML directement avec le navigateur.

La commande de compilation devient donc :

emcc -sSINGLE_FILE hello.c -o hello.singlefile.html

C'est tout de suite beaucoup plus pratique ! 😁️

--------------------------------------------------------------------------------

📝️ Note:

--------------------------------------------------------------------------------

Vous pouvez retrouver le code source de cet exemple ainsi qu'une version de démo en ligne sur Github :

Démo

Code source

--------------------------------------------------------------------------------

Second programme WebAssembly : appeler les fonctions WASM depuis JavaScript

Pour ce second programme, on va voir comment on appelle une fonction WebAssembly depuis du code JavaScript, comment lui passer des paramètres et comment récupérer sa valeur de retour. On va également voir comment intégrer tout ça dans notre propre page Web plutôt que d'utiliser celle générée par Emscripten.

Pour ce programme, on aura les fichiers suivants :

example.c

On va donc écrire un fichier "example.c" contenant une fonction permettant d'additionner deux nombres (oui je sais, c'est pas foufou comme exemple, mais ça a le mérite d'être simple). Voici le contenu de ce fichier :

#include <emscripten.h>

EMSCRIPTEN_KEEPALIVE int sum(int a, int b) {
    return a + b;
}

On peut remarquer que la fonction sum() dans le code ci-dessus est précédée par un mystérieux EMSCRIPTEN_KEEPALIVE. Cette macro permet d'indiquer deux choses au compilateur :

On notera également l'inclusion de <emscripten.h> : on en a besoin tout simplement par ce que c'est là qu'est définie la macro EMSCRIPTEN_KEEPALIVE.

On peut à présent compiler notre exemple à l'aide de la commande suivante:

emcc -sSINGLE_FILE -s"EXPORTED_RUNTIME_METHODS=['ccall']" example.c -o example.wasm.js

Il y a deux différences par rapport à la commande que l'on avait utilisée dans l'exemple précédent :

--------------------------------------------------------------------------------

📝️ Note:

--------------------------------------------------------------------------------

NOTE : Il est également possible d'utiliser l'extension .mjs pour générer des modules ES2015 importables (si vous utilisez Browserify, Webpack, etc.), ainsi que l'extension .wasm pour ne générer que les modules WebAssembly.

--------------------------------------------------------------------------------

script.js

Passons à présent au code JavaScript qui appellera notre fonction sum(). Créons donc un fichier "script.js" avec le code suivant :

Module.onRuntimeInitialized = function() {
    const numA = 32;
    const numB = 10;

    const result = Module.ccall(
        "sum",                    // Nom de la fonction WASM à appeler
        "number",                 // Type de la valeur de retour
        ["number", "number"],     // Types des paramètres de la fonction
        [numA, numB],             // Les paramètres de la fonction
    );

    console.log(`${numA} + ${numB} = ${result}`);
};

Rien de bien compliqué ici : lorsque le module WASM est prêt, (Module.onRuntimeInitialized), on appelle la fonction sum() via Module.ccall(). La syntaxe est un peu rébarbative puisqu'il faut lui indiquer explicitement tous les types des paramètres et de la valeur de retour de la fonction C, mais on verra un peu plus tard comment simplifier tout ça. 😉️

--------------------------------------------------------------------------------

⚠️ Warning

--------------------------------------------------------------------------------

Attention : il est important de noter que le module WASM n'est pas encore prêt à fonctionner lorsque la page Web a fini de charger (window.onload). Si du code doit être immédiatement exécuté au chargement de la page il vous faudra definir le callback Module.onRuntimeInitialized à la place.

--------------------------------------------------------------------------------

index.html

Maintenant qu'on a notre code C et JavaScript de prêts, il ne nous reste plus qu'à écrire notre page HTML :

<!DOCTYPE html>

<html>

    <head>
        <meta charset="UTF-8" />
        <title>WebAssembly Example 02</title>
    </head>

    <body>
        <script src="./example.wasm.js"></script>
        <script src="./script.js"></script>
    </body>

</html>

Lancement du programme

On peut maintenant ouvrir notre page "index.html" dans un navigateur et ouvrir la console JavaScript pour admirer le résultat.

Apperçu du résultat dans la console JavaScript

--------------------------------------------------------------------------------

📝️ Note:

--------------------------------------------------------------------------------

Vous pouvez une nouvelle fois retrouver le code source de cet exemple ainsi qu'une version de démo en ligne (dans une version un peu améliorée) sur Github :

Capture d'écran de la version améliorée du second exemple dans un navigateur

Démo

Code source

--------------------------------------------------------------------------------

Troisième programme WebAssembly : pointeurs et tableaux

Pour ce troisième programme, on va voir comment utiliser des pointeurs et comment passer des tableaux entre le code JavaScript et le module WebAssembly. On va donc écrire un petit programme de traitement d'image qui travaillera sur les pixels d'un canvas.

Le traitement en question ne sera pas très compliqué : on va implémenter un seuil. Tous les pixels en dessous du seuil seront affichés en noir et tout ceux au-dessus en blanc, ce qui nous donnera un résultat similaire à celui-ci :

Exemple d'un seuil sur une image

Pour cet exemple, on va avoir besoin des fichiers suivants :

index.html

Commençons cette fois-ci par la page Web :

<!DOCTYPE html>

<html>

    <head>
        <meta charset="UTF-8" />
        <title>WebAssembly Example 03</title>
    </head>

    <body>
        <img src="./image.jpg" id="image" />
        <canvas id="canvas"></canvas>
        <script src="./example.wasm.js"></script>
        <script src="./script.js"></script>
    </body>

</html>

Rien de bien différent par rapport à l'exemple précédent, si ce n'est la présence d'une image, qui sera celle que l'on va traiter, et d'un canvas qui permettra d'afficher le résultat.

example.c

Pour la partie en C, on va créer le fichier "example.c" avec le contenu suivant :

#include <stdlib.h>
#include <stdint.h>
#include <emscripten.h>

EMSCRIPTEN_KEEPALIVE uint8_t* allocBuffer(int size) {
    return malloc(size * sizeof(uint8_t));
}

EMSCRIPTEN_KEEPALIVE void freeBuffer(uint8_t* buffer) {
    free(buffer);
}

Comme vous pouvez le constater, ici il n'est pas encore question de jouer avec les pixels d'une image. Avant de pouvoir envoyer un tableau de pixels depuis le JavaScript, encore faut-il être en mesure d'allouer la mémoire nécessaire pour l'accueillir.

On a donc les deux fonctions suivantes qui nous serviront à gérer la mémoire :

Ces fonctions seront à appeler depuis le code JavaScript lorsque l'on voudra passer un tableau à la fonction WebAssembly qui fera le traitement d'image.

--------------------------------------------------------------------------------

📝️ Note:

--------------------------------------------------------------------------------

NOTE : on peut également remarquer que j'ai utilié le type uint8_t, définit dans <stdint.h>, afin que le type du buffer soit bien explicite. J'aurais également pu le déclarer en tant que unsigned char, ça aurait donné le même résultat.

--------------------------------------------------------------------------------

Passons maintenant à la fonction threshold() qui fera effectivement le traitement d'image :

EMSCRIPTEN_KEEPALIVE void threshold(uint8_t* pixels, int width, int height, int threshold) {
    // Comme il y a 4 canaux dans notre image (Rouge, Vert, Bleu, Alpha),
    // la taille du tableau fait : largeur × hauteur × 4
    int array_length = width * height * 4;

    // Ici, i = i + 4 car on navigue de pixel en pixel, et qu'un pixel est
    // composé de 4 composantes (RGBA).
    for (int i = 0 ; i < array_length ; i += 4) {
        int red = pixels[i+0];
        int green = pixels[i+1];
        int blue = pixels[i+2];
        // On ignore la case [i+3] puisqu'on a pas besoin de toucher au
        // canal alpha qui code la transparence du pixel.

        // Calcul de la luminosité du pixel à partir de ses couleurs.
        int brightness = 0.299 * red + 0.587 * green + 0.114 * blue;

        // Si la luminosité du pixel est en dessous du seuil donné, on le
        // change en noir, sinon on le change en blanc.
        if (brightness < threshold) {
            pixels[i+0] = 0;
            pixels[i+1] = 0;
            pixels[i+2] = 0;
        } else {
            pixels[i+0] = 255;
            pixels[i+1] = 255;
            pixels[i+2] = 255;
        }
    }
}

La fonction ci-dessus est relativement simple. Elle prend en paramètre :

Ensuite on boucle sur tous les pixels de l'image. On calcule la luminosité du pixel puis on remplace sa couleur par du noir si la luminosité est en dessous du seuil, ou par du blanc si elle est au-dessus.

Une fois le code écrit, il ne nous reste plus qu'à compiler le programme à l'aide de la commande suivante :

emcc -sSINGLE_FILE -s"EXPORTED_RUNTIME_METHODS=['cwrap']" example.c -o example.wasm.js

Les plus attentifs d'entre vous auront remarqué qu'il y a une légère différence par rapport à la commande de l'exemple précédent : ici j'exporte la fonction du runtime cwrap() au lieu de ccall().

On a vu qu'il était en effet assez pénible d'appeler les fonctions WebAssembly avec ccall() car il faut passer plein de paramètres liés aux types. cwrap() va nous permettre de rendre les choses plus simples.

script.js

Il ne nous reste plus qu'à écrire le code JavaScript qui fera appel à nos fonctions WebAssembly. Et on va commencer par binder les fonctions WASM en JavaScript afin de pouvoir les appeler de manière transparente, sans avoir à spécifier les types des paramètres à chaque fois :

const API = {
    allocBuffer: Module.cwrap("allocBuffer", "number", ["number"]),
    freeBuffer: Module.cwrap("freeBuffer", "", ["number"]),
    threshold: Module.cwrap("threshold", "", ["number", "number", "number", "number"]),
};

La fonction Module.cwrap() prend des paramètres très similaires à ceux de Module.ccall(), et elle nous retourne des fonctions JavaScript que l'on va pouvoir appeler par la suite.

Écrivons à présent la fonction qui fera les manipulations sur l'image, le canvas et les appels au code WebAssembly :

function thresholdImageOnCanvas(image, canvas, threshold=127) {
    // On commence par redimensionner le canvas à la même taille que l'image.
    canvas.width = image.width;
    canvas.height = image.height;

    // On récupère le contexte 2D du canvas afin de pouvoir dessiner dedans.
    const ctx = canvas.getContext("2d");

    // On dessine l'image dans le canvas.
    ctx.drawImage(image, 0, 0);

    // On récupère les pixels qui composent l'image sous forme de tableau.
    //
    // L'objet « imageData » retourné est composé de la manière suivante :
    //
    // {
    //     width: ...,
    //     height: ...,
    //     data: [red1, green1, blue1, alpha1, red2, green2, blue2, alpha2, …]
    //     //     ---------------------------  ---------------------------
    //     //      PIXEL 1 (en haut à gauche)             PIXEL 2
    // }
    //
    // NOTE: « imageData.data » est un Uint8ClampedArray.
    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);

    // Allocation du buffer qui contiendra nos pixels dans le module WASM.
    //
    // « pixel_p » est un pointeur vers le buffer (dans le monde
    // JavaScript, il ne s'agit que d'un simple nombre).
    const pixels_p = API.allocBuffer(imageData.data.length);

    // Copie des pixels dans le buffer préalablement alloué.
    //
    // « Module.HEAP8 » est un Uint8Array qui représente la « RAM » (le tas
    // pour être plus précis) du module WASM. Le pointeur « pixel_p » est en
    // fait simplement l'index de la case où commence notre buffer dans ce
    // tableau.
    Module.HEAP8.set(imageData.data, pixels_p);

    // Appel de la fonction WASM qui va opérer les modifications de
    // l'image.
    API.threshold(pixels_p, imageData.width, imageData.height, threshold);

    // On récupère les pixels modifiés dans la mémoire du programme WASM et
    // on les remet en place dans l'objet « imageData ».
    imageData.data.set(new Uint8Array(
        Module.HEAP8.buffer, pixels_p, imageData.data.length
    ));

    // On remet les pixels dans le canvas.
    ctx.putImageData(imageData, 0, 0);

    // Et enfin on oublie pas de libérer la mémoire.
    API.freeBuffer(pixels_p);
}

Je pense que le code est suffisamment commenté pour être compréhensible. J'avoue qu'au début ça fait un peu bizarre de se retrouver avec des pointeurs en JavaScript, mais on s'y fait. 😉️

Et pour terminer, on rajoute quelques lignes de code pour appeler notre fonction dès que le module WASM est initialisé et que l'image à traiter est chargée :

Module.onRuntimeInitialized = function() {
    const image = document.getElementById("image");
    const canvas = document.getElementById("canvas");

    // Si l'image est chargée, on appelle directement la fonction de traitement
    // d'image, sinon on attend qu'elle soit effectivement chargée pour appeler
    // la fonction.
    if (image.complete) {
        thresholdImageOnCanvas(image, canvas);
    } else {
        image.onload = function() {
            thresholdImageOnCanvas(image, canvas);
        };
    }
};

Lancement du programme

Pour lancer notre programme, on ne va pas pouvoir se contenter de simplement ouvrir notre page Web avec notre navigateur cette fois-ci. À cause des sécurités en place dans les navigateurs, si une image d'une origine différente de celle de la page Web est collée dans un canvas, il n'est plus possible de récupérer les pixels de ce dernier. Il nous faut donc servir notre page Web et notre image avec un serveur HTTP, et sur la même « adresse ».

Comme pour le premier exemple de cet article, il est possible là aussi d'utiliser le serveur HTTP intégré à Python :

cd /chemin/vers/lexemple3/
python3 -m http.server

Il suffit ensuite de se rendre à l'adresse suivante pour admirer le résultat :

http://localhost:8000/

--------------------------------------------------------------------------------

📝️ Note:

--------------------------------------------------------------------------------

Le code source de cet exemple ainsi qu'une version de démo en ligne sont disponibles sur Github :

Démo

Code source

--------------------------------------------------------------------------------

Conclusion

J'aurais voulu aborder de nombreux autres sujets dans cet article, comme l'appel de fonction JavaScript depuis le module WASM, ou l'accès à certaines API des navigateurs (Web Workers, Fetch API, Gamepad API,...), mais il ne s'agit que d'une introduction au sujet, on ne peut pas être exhaustifs. 😅️

À l'origine je voulais également ajouter un benchmark à l'article afin de comparer les performances de JavaScript et de WebAssembly, mais l'article commençait à devenir trop long à mon goût. Je vais donc rédiger un article dédié à ce benchmark.

J'espère que cet article vous aura permis d'en apprendre un peu plus sur le fonctionnement de WebAssembly, et je vous retrouve d'ici deux semaines environ pour un benchmark WebAssembly vs JavaScript [EDIT: ça à prit un peu plus de temps que prévu, mais le benchmark est par là]. 😋️

le benchmark est par là

Pour aller plus loin :

Documentation et tuto sur WebAssembly chez MDN

Un autre tuto sur WebAssembly

Documentation du compilateur Emscripten

Specification de l'interface JavaScript / WebAssembly du W3C

--------------------------------------------------------------------------------

La photo de papillon utilisée pour le 3ème exemple a été prise par Atanu Bose et est placée sous la licence Creative Commons Attribution-Share Alike 4.0.

photo de papillon

--------------------------------------------------------------------------------

🏠 Accueil