WebVR Parte 3: Desbloqueo del potencial de WebAssembly y AssemblyScript

Publicado: 2022-03-11

WebAssembly definitivamente no es un reemplazo para JavaScript como la lingua franca de la web y el mundo.

WebAssembly (abreviado Wasm) es un formato de instrucción binaria para una máquina virtual basada en pila. Wasm está diseñado como un objetivo portátil para la compilación de lenguajes de alto nivel como C/C++/Rust, lo que permite la implementación en la web para aplicaciones de cliente y servidor”. –WebAssembly.org

Es importante distinguir que WebAssembly no es un lenguaje. WebAssembly es como un archivo '.exe', o incluso mejor, un archivo Java '.class'. Es compilado por el desarrollador web desde otro idioma, luego descargado y ejecutado en su navegador.

WebAssembly le está dando a JavaScript todas las funciones que ocasionalmente queríamos tomar prestadas pero que nunca quisimos tener. Al igual que alquilar un barco o un caballo, WebAssembly nos permite viajar a otros idiomas sin tener que hacer elecciones extravagantes de "estilo de vida lingüístico". Esto ha permitido que la web se centre en cosas importantes como ofrecer funciones y mejorar la experiencia del usuario.

Más de 20 lenguajes se compilan en WebAssembly: Rust, C/C++, C#/.Net, Java, Python, Elixir, Go y, por supuesto, JavaScript.

Si recuerda el diagrama de arquitectura de nuestra simulación, delegamos toda la simulación a nBodySimulator , por lo que administra el trabajador web.

Diagrama de arquitectura de simulación
Figura 1: Arquitectura general.

Si recuerda la publicación de introducción, nBodySimulator tiene una función de step() que se llama cada 33 ms. La función step() hace estas cosas, numeradas en el diagrama de arriba:

  1. CalculaForces calculateForces() de nBodySimulator llama a this.worker.postMessage() para iniciar el cálculo.
  2. workerWasm.js this.onmessage() recibe el mensaje.
  3. workerWasm.js ejecuta sincrónicamente la función nBodyForces() de nBodyForces.wasm.
  4. workerWasm.js responde usando this.postMessage() al hilo principal con las nuevas fuerzas.
  5. El subproceso principal this.worker.onMessage() los datos y las llamadas devueltos.
  6. applyForces applyForces() de nBodySimulator para actualizar las posiciones de los cuerpos.
  7. Finalmente, el visualizador vuelve a pintar.

Subproceso de interfaz de usuario, subproceso de trabajador web
Figura 2: Dentro de la función step() del simulador

En la publicación anterior, construimos el trabajador web que envuelve nuestros cálculos WASM. Hoy, estamos construyendo la pequeña caja etiquetada como "WASM" y moviendo datos dentro y fuera.

Para simplificar, elegí AssemblyScript como lenguaje de código fuente para escribir nuestros cálculos. AssemblyScript es un subconjunto de TypeScript, que es un JavaScript escrito, por lo que ya lo conoce.

Por ejemplo, esta función de AssemblyScript calcula la gravedad entre dos cuerpos: :f64 f64 en someVar:f64 marca la variable someVar como flotante para el compilador. Recuerde que este código se compila y ejecuta en un tiempo de ejecución completamente diferente al 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 }

Esta función de AssemblyScript toma (x, y, z, masa) para dos cuerpos y devuelve una matriz de tres flotantes que describen el vector de fuerza (x, y, z) que los cuerpos aplican entre sí. No podemos llamar a esta función desde JavaScript porque JavaScript no tiene idea de dónde encontrarla. Tenemos que “exportarlo” a JavaScript. Esto nos lleva a nuestro primer desafío técnico.

Importaciones y exportaciones de ensamblaje web

En ES6, pensamos en las importaciones y exportaciones en código JavaScript y usamos herramientas como Rollup o Webpack para crear código que se ejecuta en navegadores heredados para manejar import y require() . Esto crea un árbol de dependencia de arriba hacia abajo y permite tecnología genial como "sacudir árboles" y dividir códigos.

En WebAssembly, las importaciones y exportaciones realizan tareas diferentes a las de una importación ES6. Importaciones/exportaciones de WebAssembly:

  • Proporcione un entorno de tiempo de ejecución para el módulo WebAssembly (p. ej., funciones trace() y abort() ).
  • Importe y exporte funciones y constantes entre tiempos de ejecución.

En el siguiente código, env.abort y env.trace son parte del entorno que debemos proporcionar al módulo WebAssembly. Las funciones nBodyForces.logI y amigos proporcionan mensajes de depuración a la consola. Tenga en cuenta que pasar cadenas dentro/fuera de WebAssembly no es trivial ya que los únicos tipos de WebAssembly son números i32, i64, f32, f64, con referencias i32 a una memoria lineal abstracta.

Nota: estos ejemplos de código alternan entre código JavaScript (el trabajador web) y AssemblyScript (el código 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(", ")); } } }

En nuestro código AssemblyScript, podemos completar la importación de estas funciones así:

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

Nota : La cancelación y el seguimiento se importan automáticamente .

Desde AssemblyScript, podemos exportar nuestra interfaz. Aquí hay algunas constantes exportadas:

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

Y aquí está la exportación de nBodyForces() que llamaremos desde JavaScript. Exportamos el tipo Float64Array en la parte superior del archivo para que podamos usar el cargador de JavaScript de AssemblyScript en nuestro trabajador web para obtener los datos (ver a continuación):

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

Artefactos de WebAssembly: .wasm y .wat

Cuando nuestro AssemblyScript nBodyForces.ts se compila en un binario WebAssembly nBodyForces.wasm , hay una opción para crear también una versión de "texto" que describe las instrucciones en el binario.

Artefactos de ensamblaje web
Figura 3: Recuerde, AssemblyScript es un lenguaje. WebAssembly es un compilador y un tiempo de ejecución.

Dentro del archivo nBodyForces.wat , podemos ver estas importaciones y exportaciones:

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

Ahora tenemos nuestro binario nBodyForces.wasm y un trabajador web para ejecutarlo. ¡Prepárate para el despegue! ¡Y algo de gestión de memoria!

Para completar la integración, tenemos que pasar una matriz variable de flotantes a WebAssembly y devolver una matriz variable de flotantes a JavaScript.

Con el ingenuo JavaScript burgués, me dispuse a pasar estas llamativas matrices de tamaño variable dentro y fuera de un tiempo de ejecución multiplataforma de alto rendimiento. Pasar datos a/desde WebAssembly fue, por mucho, la dificultad más inesperada de este proyecto.

Sin embargo, con muchas gracias por el trabajo pesado realizado por el equipo de AssemblyScript, podemos usar su "cargador" para ayudar:

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

El require() significa que necesitamos usar un paquete de módulos como Rollup o Webpack. Para este proyecto, elegí Rollup por su simplicidad y flexibilidad y nunca miré hacia atrás.

Recuerde que nuestro trabajador web se ejecuta en un hilo separado y es esencialmente una función onmessage() con una instrucción switch() .

loader crea nuestro módulo wasm con algunas funciones de administración de memoria extra útiles. __retain() y __release() administran las referencias de recolección de basura en el tiempo de ejecución del trabajador __allocArray() copia nuestra matriz de parámetros en la memoria del módulo wasm __getFloat64Array() copia la matriz de resultados del módulo wasm en el tiempo de ejecución del trabajador

Ahora podemos ordenar matrices flotantes dentro y fuera de nBodyForces() y completar nuestra simulación:

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

Con todo lo que hemos aprendido, repasemos nuestro proceso de trabajo web y WebAssembly. Bienvenido al nuevo navegador de la web. Estos son enlaces al código en GitHub:

  1. OBTENER índice.html
  2. principal.js
  3. nBodySimulator.js: pasa un mensaje a su trabajador web
  4. workerWasm.js: llama a la función WebAssembly
  5. nBodyForces.ts: calcula y devuelve una matriz de fuerzas
  6. workerWasm.js: devuelve los resultados al subproceso principal
  7. nBodySimulator.js - resuelve la promesa de las fuerzas
  8. nBodySimulator.js: luego aplica las fuerzas a los cuerpos y les dice a los visualizadores que pinten

A partir de aquí, ¡comencemos el espectáculo creando nBodyVisualizer.js ! Nuestra siguiente publicación crea un visualizador usando la API de Canvas, y la publicación final concluye con WebVR y Aframe.

Relacionado: Tutorial de WebAssembly/Rust: Procesamiento de audio perfecto