WebVR Teil 3: Erschließen des Potenzials von WebAssembly und AssemblyScript

Veröffentlicht: 2022-03-11

WebAssembly ist definitiv kein Ersatz für JavaScript als Lingua Franca des Webs und der Welt.

WebAssembly (abgekürzt Wasm) ist ein binäres Befehlsformat für eine stapelbasierte virtuelle Maschine. Wasm ist als portables Ziel für die Kompilierung von Hochsprachen wie C/C++/Rust konzipiert und ermöglicht die Bereitstellung im Web für Client- und Serveranwendungen.“ –WebAssembly.org

Es ist wichtig zu unterscheiden, dass WebAssembly keine Sprache ist. WebAssembly ist wie eine '.exe' - oder noch besser - eine Java '.class'-Datei. Es wird vom Webentwickler aus einer anderen Sprache kompiliert, dann heruntergeladen und in Ihrem Browser ausgeführt.

WebAssembly gibt JavaScript all die Funktionen, die wir gelegentlich ausleihen wollten, aber nie wirklich besitzen wollten. Ähnlich wie beim Mieten eines Bootes oder eines Pferdes können wir mit WebAssembly in andere Sprachen reisen, ohne extravagante „Sprachlebensstil“-Entscheidungen treffen zu müssen. Dadurch konnte sich das Web auf wichtige Dinge wie die Bereitstellung von Funktionen und die Verbesserung der Benutzererfahrung konzentrieren.

Mehr als 20 Sprachen kompilieren zu WebAssembly: Rust, C/C++, C#/.Net, Java, Python, Elixir, Go und natürlich JavaScript.

Wenn Sie sich an das Architekturdiagramm unserer Simulation erinnern, haben wir die gesamte Simulation an nBodySimulator , damit sie den Webworker verwaltet.

Architekturdiagramm der Simulation
Abbildung 1: Gesamtarchitektur.

Wenn Sie sich an den Einführungspost erinnern, hat nBodySimulator eine step() Funktion, die alle 33 ms aufgerufen wird. Die Funktion step() erledigt diese Dinge - im Diagramm oben nummeriert:

  1. computeForces calculateForces() von nBodySimulator ruft this.worker.postMessage() auf, um die Berechnung zu starten.
  2. workerWasm.js this.onmessage() erhält die Nachricht.
  3. workerWasm.js führt synchron die Funktion nBodyForces() von nBodyForces.wasm aus.
  4. workerWasm.js antwortet mit this.postMessage() an den Haupt-Thread mit den neuen Kräften.
  5. this.worker.onMessage() des Haupt-Threads marshallt die zurückgegebenen Daten und Aufrufe.
  6. applyForces applyForces() von nBodySimulator aktualisiert die Positionen der Körper.
  7. Abschließend wird der Visualizer neu gezeichnet.

UI-Thread, Web-Worker-Thread
Abbildung 2: Das Innere der step()-Funktion des Simulators

Im vorherigen Beitrag haben wir den Webworker erstellt, der unsere WASM-Berechnungen umschließt. Heute bauen wir die winzige Box mit der Bezeichnung „WASM“ und verschieben Daten hinein und hinaus.

Der Einfachheit halber habe ich AssemblyScript als Quellcodesprache gewählt, um unsere Berechnungen zu schreiben. AssemblyScript ist eine Teilmenge von TypeScript – ein typisiertes JavaScript – Sie kennen es also bereits.

Diese AssemblyScript-Funktion berechnet beispielsweise die Schwerkraft zwischen zwei Körpern: Das :f64 in someVar:f64 markiert die someVar-Variable als Float für den Compiler. Denken Sie daran, dass dieser Code in einer völlig anderen Laufzeit als JavaScript kompiliert und ausgeführt wird.

 // 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 }

Diese AssemblyScript-Funktion nimmt die (x, y, z, Masse) für zwei Körper und gibt ein Array aus drei Floats zurück, die den (x, y, z)-Kraftvektor beschreiben, den die Körper aufeinander anwenden. Wir können diese Funktion nicht von JavaScript aus aufrufen, da JavaScript keine Ahnung hat, wo sie zu finden ist. Wir müssen es nach JavaScript „exportieren“. Damit kommen wir zu unserer ersten technischen Herausforderung.

WebAssembly-Importe und -Exporte

In ES6 denken wir über Importe und Exporte in JavaScript-Code nach und verwenden Tools wie Rollup oder Webpack, um Code zu erstellen, der in älteren Browsern ausgeführt wird, um import und require() zu handhaben. Dies erstellt einen Top-Down-Abhängigkeitsbaum und ermöglicht coole Technologien wie „Tree-Shaking“ und Code-Splitting.

In WebAssembly erfüllen Importe und Exporte andere Aufgaben als ein ES6-Import. WebAssembly-Importe/Exporte:

  • Stellen Sie eine Laufzeitumgebung für das WebAssembly-Modul bereit (z. B. die Funktionen trace() und abort() ).
  • Importieren und exportieren Sie Funktionen und Konstanten zwischen Laufzeiten.

Im folgenden Code sind env.abort und env.trace Teil der Umgebung, die wir für das WebAssembly-Modul bereitstellen müssen. Die Funktionen nBodyForces.logI und Co. liefern Debugging-Meldungen an die Konsole. Beachten Sie, dass das Übergeben von Zeichenfolgen in/aus WebAssembly nicht trivial ist, da die einzigen Typen von WebAssembly i32-, i64-, f32-, f64-Zahlen mit i32-Verweise auf einen abstrakten linearen Speicher sind.

Hinweis: Diese Codebeispiele wechseln zwischen JavaScript-Code (dem Webworker) und AssemblyScript (dem WASM-Code) hin und her.

 // 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(", ")); } } }

In unserem AssemblyScript-Code können wir den Import dieser Funktionen wie folgt abschließen:

 // nBodyForces.ts declare function logI(data: i32): void declare function logF(data: f64): void

Hinweis : Abort und Trace werden automatisch importiert .

Aus AssemblyScript können wir unsere Schnittstelle exportieren. Hier sind einige exportierte Konstanten:

 // 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

Und hier ist der Export von nBodyForces() , den wir aus JavaScript aufrufen werden. Wir exportieren den Typ Float64Array am Anfang der Datei, damit wir den JavaScript-Loader von AssemblyScript in unserem Webworker verwenden können, um die Daten abzurufen (siehe unten):

 // 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 }

WebAssembly-Artefakte: .wasm und .wat

Wenn unser AssemblyScript nBodyForces.ts in eine WebAssembly nBodyForces.wasm Binärdatei kompiliert wird, besteht die Möglichkeit, auch eine „Text“-Version zu erstellen, die die Anweisungen in der Binärdatei beschreibt.

WebAssembly-Artefakte
Abbildung 3: Denken Sie daran, dass AssemblyScript eine Sprache ist. WebAssembly ist ein Compiler und eine Laufzeitumgebung.

In der Datei nBodyForces.wat können wir diese Importe und Exporte sehen:

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

Wir haben jetzt unsere nBodyForces.wasm Binärdatei und einen Web-Worker, um sie auszuführen. Mach dich bereit für Blastoff! Und etwas Speicherverwaltung!

Um die Integration abzuschließen, müssen wir ein variables Array von Gleitkommazahlen an WebAssembly übergeben und ein variables Array von Gleitkommazahlen an JavaScript zurückgeben.

Mit naivem JavaScript-Bourgeois machte ich mich daran, diese knalligen Arrays mit variabler Größe mutwillig in und aus einer plattformübergreifenden Hochleistungslaufzeit zu übergeben. Das Übergeben von Daten an/von WebAssembly war bei weitem die unerwartetste Schwierigkeit in diesem Projekt.

Vielen Dank für das schwere Heben, das das AssemblyScript-Team geleistet hat, aber wir können seinen „Loader“ verwenden, um zu helfen:

 // workerWasm.js - our web worker /** * AssemblyScript loader adds helpers for moving data to/from AssemblyScript. * Highly recommended */ const loader = require("assemblyscript/lib/loader")

Das require() bedeutet, dass wir einen Modulbundler wie Rollup oder Webpack verwenden müssen. Für dieses Projekt habe ich mich wegen seiner Einfachheit und Flexibilität für Rollup entschieden und es nie bereut.

Denken Sie daran, dass unser Webworker in einem separaten Thread läuft und im Wesentlichen eine onmessage() Funktion mit einer switch() Anweisung ist.

loader erstellt unser wasm-Modul mit einigen besonders praktischen Speicherverwaltungsfunktionen. __retain() und __release() verwalten Garbage-Collection-Referenzen in der Worker-Laufzeit __allocArray() kopiert unser Parameter-Array in den Speicher des wasm-Moduls __getFloat64Array() kopiert das Ergebnis-Array aus dem wasm-Modul in die Worker-Laufzeit

Wir können jetzt Float-Arrays in und aus nBodyForces() und unsere Simulation vervollständigen:

 // 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 }) } }

Lassen Sie uns nach allem, was wir gelernt haben, unsere Webworker- und WebAssembly-Reise Revue passieren lassen. Willkommen im neuen Browser-Backend des Webs. Dies sind Links zum Code auf GitHub:

  1. GET-Index.html
  2. main.js
  3. nBodySimulator.js - leitet eine Nachricht an seinen Webworker weiter
  4. workerWasm.js – ruft die WebAssembly-Funktion auf
  5. nBodyForces.ts - berechnet und gibt ein Array von Kräften zurück
  6. workerWasm.js - gibt die Ergebnisse an den Haupt-Thread zurück
  7. nBodySimulator.js - löst das Versprechen für Kräfte auf
  8. nBodySimulator.js - wendet dann die Kräfte auf die Körper an und weist die Visualisierer an, zu malen

Lassen Sie uns von hier aus die Show beginnen, indem nBodyVisualizer.js ! Unser nächster Beitrag erstellt einen Visualizer mithilfe der Canvas-API, und der letzte Beitrag endet mit WebVR und Aframe.

Siehe auch : WebAssembly/Rust-Tutorial: Perfekte Audioverarbeitung