WebVR Partie 3 : Libérer le potentiel de WebAssembly et AssemblyScript
Publié: 2022-03-11WebAssembly ne remplace certainement pas JavaScript en tant que lingua franca du Web et du monde.
WebAssembly (en abrégé Wasm) est un format d'instruction binaire pour une machine virtuelle basée sur une pile. Wasm est conçu comme une cible portable pour la compilation de langages de haut niveau comme C/C++/Rust, permettant un déploiement sur le Web pour les applications client et serveur. –WebAssembly.org
Il est important de distinguer que WebAssembly n'est pas un langage. WebAssembly est comme un fichier '.exe' - ou mieux encore - un fichier Java '.class'. Il est compilé par le développeur Web à partir d'un autre langage, puis téléchargé et exécuté sur votre navigateur.
WebAssembly donne à JavaScript toutes les fonctionnalités que nous voulions parfois emprunter mais que nous n'avons jamais vraiment voulu posséder. Tout comme la location d'un bateau ou d'un cheval, WebAssembly nous permet de voyager dans d'autres langues sans avoir à faire des choix extravagants de "style de vie linguistique". Cela a permis au Web de se concentrer sur des choses importantes telles que la fourniture de fonctionnalités et l'amélioration de l'expérience utilisateur.
Plus de 20 langages compilent vers WebAssembly : Rust, C/C++, C#/.Net, Java, Python, Elixir, Go, et bien sûr JavaScript.
Si vous vous souvenez du schéma d'architecture de notre simulation, nous avons délégué l'intégralité de la simulation à nBodySimulator
, il gère donc le web worker.
Si vous vous souvenez de l'article d'introduction, nBodySimulator
a une fonction step()
appelée toutes les 33 ms. La fonction step()
fait ces choses - numérotées dans le diagramme ci-dessus :
-
calculateForces()
de nBodySimulator appellethis.worker.postMessage()
pour démarrer le calcul. - workerWasm.js
this.onmessage()
obtient le message. - workerWasm.js exécute de manière synchrone la fonction
nBodyForces()
de nBodyForces.wasm. - workerWasm.js répond en utilisant
this.postMessage()
au thread principal avec les nouvelles forces. -
this.worker.onMessage()
du thread principal rassemble les données et les appels renvoyés. - nBodySimulator's
applyForces()
pour mettre à jour les positions des corps. - Enfin, le visualiseur se repeint.
Dans le post précédent, nous avons construit le web worker qui encapsule nos calculs WASM. Aujourd'hui, nous construisons la petite boîte étiquetée "WASM" et transférons les données à l'intérieur et à l'extérieur.
Pour plus de simplicité, j'ai choisi AssemblyScript comme langage de code source pour écrire nos calculs. AssemblyScript est un sous-ensemble de TypeScript - qui est un JavaScript typé - vous le savez donc déjà.
Par exemple, cette fonction AssemblyScript calcule la gravité entre deux corps : Le :f64
dans someVar:f64
marque la variable someVar comme flottant pour le compilateur. N'oubliez pas que ce code est compilé et exécuté dans un environnement d'exécution complètement différent de JavaScript.
// AssemblyScript - a TypeScript-like language that compiles to WebAssembly // src/assembly/nBodyForces.ts /** * Given two bodies, calculate the Force of Gravity, * then return as a 3-force vector (x, y, z) * * Sometimes, the force of gravity is: * * Fg = G * mA * mB / r^2 * * Given: * - Fg = Force of gravity * - r = sqrt ( dx + dy + dz) = straight line distance between 3d objects * - G = gravitational constant * - mA, mB = mass of objects * * Today, we're using better-gravity because better-gravity can calculate * force vectors without polar math (sin, cos, tan) * * Fbg = G * mA * mB * dr / r^3 // using dr as a 3-distance vector lets * // us project Fbg as a 3-force vector * * Given: * - Fbg = Force of better gravity * - dr = (dx, dy, dz) // a 3-distance vector * - dx = bodyB.x - bodyA.x * * Force of Better-Gravity: * * - Fbg = (Fx, Fy, Fz) = the change in force applied by gravity each * body's (x,y,z) over this time period * - Fbg = G * mA * mB * dr / r^3 * - dr = (dx, dy, dz) * - Fx = Gmm * dx / r3 * - Fy = Gmm * dy / r3 * - Fz = Gmm * dz / r3 * * From the parameters, return an array [fx, fy, fz] */ function twoBodyForces(xA: f64, yA: f64, zA: f64, mA: f64, xB: f64, yB: f64, zB: f64, mB: f64): f64[] { // Values used in each x,y,z calculation const Gmm: f64 = G * mA * mB const dx: f64 = xB - xA const dy: f64 = yB - yA const dz: f64 = zB - zA const r: f64 = Math.sqrt(dx * dx + dy * dy + dz * dz) const r3: f64 = r * r * r // Return calculated force vector - initialized to zero const ret: f64[] = new Array<f64>(3) // The best not-a-number number is zero. Two bodies in the same x,y,z if (isNaN(r) || r === 0) return ret // Calculate each part of the vector ret[0] = Gmm * dx / r3 ret[1] = Gmm * dy / r3 ret[2] = Gmm * dz / r3 return ret }
Cette fonction AssemblyScript prend le (x, y, z, masse) pour deux corps et renvoie un tableau de trois flottants décrivant le vecteur de force (x, y, z) que les corps s'appliquent les uns aux autres. Nous ne pouvons pas appeler cette fonction à partir de JavaScript car JavaScript ne sait pas où la trouver. Nous devons "l'exporter" vers JavaScript. Cela nous amène à notre premier défi technique.
Importations et exportations WebAssembly
Dans ES6, nous pensons aux importations et aux exportations dans le code JavaScript et utilisons des outils comme Rollup ou Webpack pour créer du code qui s'exécute dans les anciens navigateurs pour gérer import
et require()
. Cela crée une arborescence de dépendances descendante et permet des technologies intéressantes telles que le "secouage d'arbres" et le fractionnement de code.
Dans WebAssembly, les importations et les exportations accomplissent des tâches différentes de celles d'une importation ES6. Importations/exportations WebAssembly :
- Fournir un environnement d'exécution pour le module WebAssembly (par exemple, les fonctions
trace()
etabort()
). - Fonctions d'importation et d'exportation et constantes entre les runtimes.
Dans le code ci-dessous, env.abort
et env.trace
font partie de l'environnement que nous devons fournir au module WebAssembly. Les fonctions nBodyForces.logI
et friends fournissent des messages de débogage à la console. Notez que le passage de chaînes dans/hors de WebAssembly n'est pas trivial car les seuls types de WebAssembly sont les nombres i32, i64, f32, f64, avec des références i32 à une mémoire linéaire abstraite.
Remarque : ces exemples de code alternent entre le code JavaScript (le Web Worker) et AssemblyScript (le code WASM).
// Web Worker JavaScript in workerWasm.js /** * When we instantiate the Wasm module, give it a context to work in: * nBodyForces: {} is a table of functions we can import into AssemblyScript. See top of nBodyForces.ts * env: {} describes the environment sent to the Wasm module as it's instantiated */ const importObj = { nBodyForces: { logI(data) { console.log("Log() - " + data); }, logF(data) { console.log("Log() - " + data); }, }, env: { abort(msg, file, line, column) { // wasm.__getString() is added by assemblyscript's loader: // https://github.com/AssemblyScript/assemblyscript/tree/master/lib/loader console.error("abort: (" + wasm.__getString(msg) + ") at " + wasm.__getString(file) + ":" + line + ":" + column); }, trace(msg, n) { console.log("trace: " + wasm.__getString(msg) + (n ? " " : "") + Array.prototype.slice.call(arguments, 2, 2 + n).join(", ")); } } }
Dans notre code AssemblyScript, nous pouvons terminer l'import de ces fonctions comme suit :
// nBodyForces.ts declare function logI(data: i32): void declare function logF(data: f64): void
Remarque : L'abandon et la trace sont importés automatiquement .
Depuis AssemblyScript, nous pouvons exporter notre interface. Voici quelques constantes exportées :
// src/assembly/nBodyForces.ts // Gravitational constant. Any G could be used in a game. // This value is best for a scientific simulation. export const G: f64 = 6.674e-11; // for sizing and indexing arrays export const bodySize: i32 = 4 export const forceSize: i32 = 3
Et voici l'export de nBodyForces()
que nous appellerons depuis JavaScript. Nous exportons le type Float64Array
en haut du fichier afin que nous puissions utiliser le chargeur JavaScript d'AssemblyScript dans notre web worker pour obtenir les données (voir ci-dessous) :

// src/assembly/nBodyForces.ts export const FLOAT64ARRAY_ID = idof<Float64Array>(); ... /** * Given N bodies with mass, in a 3d space, calculate the forces of gravity to be applied to each body. * * This function is exported to JavaScript, so only takes/returns numbers and arrays. * For N bodies, pass and array of 4N values (x,y,z,mass) and expect a 3N array of forces (x,y,z) * Those forces can be applied to the bodies mass to update its position in the simulation. * Calculate the 3-vector each unique pair of bodies applies to each other. * * 0 1 2 3 4 5 * 0 xxxxx * 1 xxxx * 2 xxx * 3 xx * 4 x * 5 * * Sum those forces together into an array of 3-vector x,y,z forces * * Return 0 on success */ export function nBodyForces(arrBodies: Float64Array): Float64Array { // Check inputs const numBodies: i32 = arrBodies.length / bodySize if (arrBodies.length % bodySize !== 0) trace("INVALID nBodyForces parameter. Chaos ensues...") // Create result array. This should be garbage collected later. let arrForces: Float64Array = new Float64Array(numBodies * forceSize) // For all bodies: for (let i: i32 = 0; i < numBodies; i++) { // Given body i: pair with every body[j] where j > i for (let j: i32 = i + 1; j < numBodies; j++) { // Calculate the force the bodies apply to one another const bI: i32 = i * bodySize const bJ: i32 = j * bodySize const f: f64[] = twoBodyForces( arrBodies[bI], arrBodies[bI + 1], arrBodies[bI + 2], arrBodies[bI + 3], // x,y,z,m arrBodies[bJ], arrBodies[bJ + 1], arrBodies[bJ + 2], arrBodies[bJ + 3], // x,y,z,m ) // Add this pair's force on one another to their total forces applied x,y,z const fI: i32 = i * forceSize const fJ: i32 = j * forceSize // body0 arrForces[fI] = arrForces[fI] + f[0] arrForces[fI + 1] = arrForces[fI + 1] + f[1] arrForces[fI + 2] = arrForces[fI + 2] + f[2] // body1 arrForces[fJ] = arrForces[fJ] - f[0] // apply forces in opposite direction arrForces[fJ + 1] = arrForces[fJ + 1] - f[1] arrForces[fJ + 2] = arrForces[fJ + 2] - f[2] } } // For each body, return the sum of forces all other bodies applied to it. // If you would like to debug wasm, you can use trace or the log functions // described in workerWasm when we initialized // Eg trace("nBodyForces returns (b0x, b0y, b0z, b1z): ", 4, arrForces[0], arrForces[1], arrForces[2], arrForces[3]) // x,y,z return arrForces // success }
Artefacts WebAssembly : .wasm et .wat
Lorsque notre AssemblyScript nBodyForces.ts
est compilé dans un binaire WebAssembly nBodyForces.wasm
, il existe une option pour créer également une version "texte" décrivant les instructions dans le binaire.
Dans le fichier nBodyForces.wat
, nous pouvons voir ces importations et exportations :
;; This is a comment in nBodyForces.wat (module ;; compiler defined types (type $FUNCSIG$iii (func (param i32 i32) (result i32))) … ;; Expected imports from JavaScript (import "env" "abort" (func $~lib/builtins/abort (param i32 i32 i32 i32))) (import "env" "trace" (func $~lib/builtins/trace (param i32 i32 f64 f64 f64 f64 f64))) ;; Memory section defining data constants like strings (memory $0 1) (data (i32.const 8) "\1e\00\00\00\01\00\00\00\01\00\00\00\1e\00\00\00~\00l\00i\00b\00/\00r\00t\00/\00t\00l\00s\00f\00.\00t\00s\00") ... ;; Our global constants (not yet exported) (global $nBodyForces/FLOAT64ARRAY_ID i32 (i32.const 3)) (global $nBodyForces/G f64 (f64.const 6.674e-11)) (global $nBodyForces/bodySize i32 (i32.const 4)) (global $nBodyForces/forceSize i32 (i32.const 3)) ... ;; Memory management functions we'll use in a minute (export "memory" (memory $0)) (export "__alloc" (func $~lib/rt/tlsf/__alloc)) (export "__retain" (func $~lib/rt/pure/__retain)) (export "__release" (func $~lib/rt/pure/__release)) (export "__collect" (func $~lib/rt/pure/__collect)) (export "__rtti_base" (global $~lib/rt/__rtti_base)) ;; Finally our exported constants and function (export "FLOAT64ARRAY_ID" (global $nBodyForces/FLOAT64ARRAY_ID)) (export "G" (global $nBodyForces/G)) (export "bodySize" (global $nBodyForces/bodySize)) (export "forceSize" (global $nBodyForces/forceSize)) (export "nBodyForces" (func $nBodyForces/nBodyForces)) ;; Implementation details ...
Nous avons maintenant notre binaire nBodyForces.wasm
et un web worker pour l'exécuter. Préparez-vous pour le décollage ! Et un peu de gestion de la mémoire !
Pour terminer l'intégration, nous devons transmettre un tableau variable de flottants à WebAssembly et renvoyer un tableau variable de flottants à JavaScript.
Avec un bourgeois JavaScript naïf, j'ai entrepris de faire passer sans raison ces tableaux voyants de taille variable dans et hors d'un environnement d'exécution hautes performances multiplateforme. La transmission de données vers/depuis WebAssembly était, de loin, la difficulté la plus inattendue de ce projet.
Cependant, avec un grand merci pour le travail lourd effectué par l'équipe AssemblyScript, nous pouvons utiliser leur "chargeur" pour vous aider :
// workerWasm.js - our web worker /** * AssemblyScript loader adds helpers for moving data to/from AssemblyScript. * Highly recommended */ const loader = require("assemblyscript/lib/loader")
Le require()
signifie que nous devons utiliser un module bundler comme Rollup ou Webpack. Pour ce projet, j'ai choisi Rollup pour sa simplicité et sa flexibilité et je n'ai jamais regardé en arrière.
N'oubliez pas que notre travailleur Web s'exécute dans un thread séparé et est essentiellement une fonction onmessage()
avec une instruction switch()
.
loader
crée notre module wasm avec quelques fonctions de gestion de la mémoire très pratiques. __retain()
et __release()
gèrent les références de récupération de place dans le runtime du worker __allocArray()
copie notre tableau de paramètres dans la mémoire du module wasm __getFloat64Array()
copie le tableau de résultats du module wasm dans le runtime du worker
Nous pouvons maintenant rassembler des tableaux de flotteurs dans et hors de nBodyForces()
et terminer notre simulation :
// workerWasm.js /** * Web workers listen for messages from the main thread. */ this.onmessage = function (evt) { // message from UI thread var msg = evt.data switch (msg.purpose) { // Message: Load new wasm module case 'wasmModule': // Instantiate the compiled module we were passed. wasm = loader.instantiate(msg.wasmModule, importObj) // Throws // Tell nBodySimulation.js we are ready this.postMessage({ purpose: 'wasmReady' }) return // Message: Given array of floats describing a system of bodies (x,y,x,mass), // calculate the Grav forces to be applied to each body case 'nBodyForces': if (!wasm) throw new Error('wasm not initialized') // Copy msg.arrBodies array into the wasm instance, increase GC count const dataRef = wasm.__retain(wasm.__allocArray(wasm.FLOAT64ARRAY_ID, msg.arrBodies)); // Do the calculations in this thread synchronously const resultRef = wasm.nBodyForces(dataRef); // Copy result array from the wasm instance to our javascript runtime const arrForces = wasm.__getFloat64Array(resultRef); // Decrease the GC count on dataRef from __retain() here, // and GC count from new Float64Array in wasm module wasm.__release(dataRef); wasm.__release(resultRef); // Message results back to main thread. // see nBodySimulation.js this.worker.onmessage return this.postMessage({ purpose: 'nBodyForces', arrForces }) } }
Avec tout ce que nous avons appris, passons en revue notre parcours de travail Web et WebAssembly. Bienvenue dans le nouveau navigateur-backend du Web. Ce sont des liens vers le code sur GitHub :
- GET Index.html
- main.js
- nBodySimulator.js - transmet un message à son web worker
- workerWasm.js - appelle la fonction WebAssembly
- nBodyForces.ts - calcule et renvoie un tableau de forces
- workerWasm.js - renvoie les résultats au thread principal
- nBodySimulator.js - résout la promesse de forces
- nBodySimulator.js - applique ensuite les forces aux corps et indique aux visualiseurs de peindre
A partir de là, commençons le show en créant nBodyVisualizer.js
! Notre prochain article crée un visualiseur à l'aide de l'API Canvas, et le dernier article se termine avec WebVR et Aframe.