Search code examples
javascriptc++webassembly

Passing arrays and objects from JavaScript to c++ in Web Assembly


Ok, I've been pounding against this for a bit. I'm more of a JavaScript and C# focused dev, but I have some experience in c++. My problem is

  1. I need to find a simple way to take a Javascript object and pass it through WebAssembly to c++
  2. I need to do the same with Javascript arrays
  3. I probably need to do the same with Javascript arrays of Javascript objects

So starting with what I have tried on a simple array:

//c++
int testArr(int * arrIn, int length){
  int results = 0;
  for(int i = 0; i < length; i++){
    results += arrIn[i] + 1;
  }
  return results;
}


//javascript
let arr = [20, 50, 90, 62, 98];
console.log(wasmInstance.exports.testArr(arr, arr.length));

So that should take an array of integers, add them plus 1 (basically to test the loop). It returns 5. I expect it to return 325. So looking at typed arrays was the next logical step...

//c++
int testArr(int * arrIn, int length){
  int results = 0;
  for(int i = 0; i < length; i++){
    results += arrIn[i] + 1;
  }
  return results;
}


//javascript
let arr = [20, 50, 90, 62, 98];
let typedArray = new Int32Array(arr);

//test 1
console.log(wasmInstance.exports.testArr(typedArray, arr.length));

//test 2
console.log(wasmInstance.exports.testArr(typedArray.buffer, arr.length));

Test 1 returns, again, 5. Test 2 returns 0.

Now just to see if I can get c++ to return an array:

//c++
int * test(){
  int arr[] = {12, 32, 54, 31};
    return arr;
}

//Javascript
console.log(wasmInstance.exports.test());

Returns -16. Kind of funky and probably due to pointer issues between the two. Now if I try this:

//c++
int test(){
  int arr[] = {12, 32, 54, 31};
    return arr[0];
}

//Javascript
console.log(wasmInstance.exports.test());

Now it returns 12.

And so that is so far is as far as I have gotten on passing arrays, which for the most part does not seem possible. Now, passing objects. God help me. Please be kind on the c++ because its not my strongest language.

//c++
class Vector3{
  public:
    float x;
    float y;
    float z;
    
    Vector3(float X, float Y, float Z){
      x = X;
      y = Y;
      z = Z;
    }
};

int test(Vector3 position){
    return position.x;
}


//javascript
let position = {x: 21, y: 30, z: 12};
console.log(wasmInstance.exports.test(position));

This returns 0 instead of 21;

And now for the unholy trinity, an array of javascript objects...

//c++
class Vector3{
  public:
    float x;
    float y;
    float z;
    
    Vector3(float X, float Y, float Z){
      x = X;
      y = Y;
      z = Z;
    }
};

Vector3 test(Vector3 positions[], int length){
    return positions[0];
}


//javascript
let positions = [{x: 21, y: 30, z: 12},{x:15, y: 24, z: 14}]
console.log(wasmInstance.exports.test(positions, positions.length));

This returns undefined.

So the question is, am I messing up in c++, javascript, wasm, all 3, or what? I've spent 3 days scouring the internet looking for answers and the only thing I can find is declarations that this is possible with no examples or documentation to say HOW this can be done. The best documentation I've found is a DevRant, which still didn't give me an answer on this.

So is this possible and if so, are there any working examples I can follow OR is this not at all possible?


Solution

  • For me the lightbulb moment came when I realized that passing data back and forth between c/c++ and JavaScript was best done with numbers; specifically, pointers to the data and information about the data (size on the heap). Coming from the JavaScript world and working with pointers/heap is a bit intimidating but I've got a couple examples that I think give a good start.

    The snippet below shows a working example of passing an array of numbers from JavaScript to "c++" and "c++" returning the sum of the values back to JavaScript. I use quotes because the c++ code has been compiled using emscripten to a file called /build/sum.wasm and everything is "glued" together using a file called /build/sum.js that can be seen included in the /index.html file.

    const MAX_TRIES = 10;
    let numTries = 0;
    const moduleInterval = setInterval(() => {
      if (!Module) {
        numTries++;
      }
    
      if (numTries >= MAX_TRIES) {
        clearInterval(moduleInterval);
      }
    
      // Wait for "runtime initialization"
      // Module is defined in build/sum.js
      if (Module && Module.calledRun) {
        clearInterval(moduleInterval);
    
        const TYPES = {
          i8: { array: Int8Array, heap: "HEAP8" },
          i16: { array: Int16Array, heap: "HEAP16" },
          i32: { array: Int32Array, heap: "HEAP32" },
          f32: { array: Float32Array, heap: "HEAPF32" },
          f64: { array: Float64Array, heap: "HEAPF64" },
          u8: { array: Uint8Array, heap: "HEAPU8" },
          u16: { array: Uint16Array, heap: "HEAPU16" },
          u32: { array: Uint32Array, heap: "HEAPU32" }
        };
    
        function transferNumberArrayToHeap(array, type) {
          const typedArray = type.array.from(array);
          const heapPointer = Module._malloc(
            typedArray.length * typedArray.BYTES_PER_ELEMENT
          );
    
          Module[type.heap].set(typedArray, heapPointer >> 2);
    
          return heapPointer;
        }
    
        function containsFloatValue(array) {
          return array.some((value) => !Number.isInteger(value));
        }
    
        function sumArrayValues(array) {
          const hasFloatValue = containsFloatValue(array);
          let pointerToArrayOnHeap;
          try {
            pointerToArrayOnHeap = transferNumberArrayToHeap(
              array,
              hasFloatValue ? TYPES.f32 : TYPES.i32
            );
            return Module.ccall(
              hasFloatValue ? "sumFloatArray" : "sumIntArray", // The name of C++ function
              "number", // The return type
              ["number", "number"], // The argument types
              [pointerToArrayOnHeap, array.length] // The arguments
            );
            // Note: The method can also be called directly
            // return Module[hasFloatValue ? '_sumFloatArray' : '_sumIntArray'](arrayOnHeap, array.length);
          } finally {
            Module._free(pointerToArrayOnHeap);
          }
        }
    
        const sumInt = sumArrayValues([20, 50, 90, 62, 98]);
        const sumFloat = sumArrayValues([20, 50, 90, 62, 98, 0.5]);
    
        const outputElement = document.querySelector("#output");
        outputElement.innerHTML = `Sum of integers: <strong>${sumInt}</strong>
    <br>
    Sum of floats: <strong>${sumFloat}</strong>`;
      }
    }, 10);
    <!DOCTYPE html>
    <html lang="en-us">
    
    <head>
      <meta charset="utf-8" />
      <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
      <title>Sum</title>
    </head>
    
    <body>
      <div id="output"></div>
      <!-- /build/sum.js is a file built using emscripten -->
      <script src="https://w8r7ug.csb.app/build/sum.js"></script>
    </body>
    
    </html>

    The full working code, original c++ file, and README with the compilation command can be found here: https://codesandbox.io/s/cpp-javascript-webassembly-basic-example-w8r7ug/index.js.

    In short:

    1. Compile the c++ code
    em++ -o build/sum.js sum.cpp -s NO_EXIT_RUNTIME=1 -s "EXPORTED_RUNTIME_METHODS=['ccall']" -s "EXPORTED_FUNCTIONS=['_free','_malloc']" -g
    
    1. In your JavaScript file transfer the data you want to send to the heap using something like this:
    function transferNumberArrayToHeap(array, type) {
      const typedArray = type.array.from(array);
      const heapPointer = Module._malloc(
        typedArray.length * typedArray.BYTES_PER_ELEMENT
      );
    
      Module[type.heap].set(typedArray, heapPointer >> 2);
    
      return heapPointer;
    }
    
    1. Also in your JavaScript file call the compiled c++ function and pass the information about the data (pointer and size).
    Module.ccall(
        hasFloatValue ? "sumFloatArray" : "sumIntArray", // The name of C++ function
        "number", // The return type
        ["number", "number"], // The argument types
        [pointerToArrayOnHeap, array.length] // The arguments
    );
    // Or call the method  directly
    Module[hasFloatValue ? '_sumFloatArray' : '_sumIntArray'](pointerToArrayOnHeap, array.length);
    

    To answer your question, yes, it is possible to pass complex data from JavaScript to c/c++ and receive data back and above there is a working example of passing an array of numbers and receiving the sum back.




    Now I want to provide an example of more complex data including arrays-of-arrays and objects. In the end following this next example makes it much easier to pass complex data back and forth and reason about the code. This is the way that I recommend doing so even for a basic example like above.

    Using msgpack makes it easy to "exchange data among multiple languages like JSON." Compiling is the same as before. Just add a little bit of information in your c++ about how the data is defined in msgpack encode and decode the data in your JavaScript file and you are good to go.

    The full code and README can be found at https://codesandbox.io/s/cpp-javascript-webassembly-msgpack-example-wh8bwy?file=/index.js. The snippet below will also run the code.

    const MAX_TRIES = 10;
    let numTries = 0;
    const moduleInterval = setInterval(() => {
      if (!Module) {
        numTries++;
      }
    
      if (numTries >= MAX_TRIES) {
        clearInterval(moduleInterval);
      }
    
      // Wait for "runtime initialization"
      // Module is defined in build/vector3.js
      if (Module && Module.calledRun && msgpack5) {
        clearInterval(moduleInterval);
        
        const msgpack = msgpack5();
    
        const encodedPositions = msgpack.encode([{
            positionId: "position1",
            x: 21,
            y: 30,
            z: 12
          },
          {
            positionId: "position2",
            x: 15,
            y: 24,
            z: 14
          }
        ]);
        let inputPointer = Module._malloc(encodedPositions.length);
        Module.HEAP8.set(
          encodedPositions,
          inputPointer / encodedPositions.BYTES_PER_ELEMENT
        );
    
        const outputPointer = Module._malloc(8);
        const processedVector3IdsPointer = Module.ccall(
          "processVector3",
          "number", ["number", "number", "number"], [inputPointer, encodedPositions.length, outputPointer]
        );
    
        // OUTPUT
        let offset = Module.getValue(outputPointer, "i64");
        const processedVector3IdsData = new Uint8Array(
          Module.HEAPU8.subarray(
            processedVector3IdsPointer,
            processedVector3IdsPointer + offset
          )
        );
    
        const processedVector3Ids = msgpack.decode(processedVector3IdsData);
        console.log("Successfully Processed ids: ", processedVector3Ids);
    
        Module._free(inputPointer);
        Module._free(outputPointer);
        Module._free(processedVector3IdsPointer);
      }
    }, 10);
    <!DOCTYPE html>
    <html lang="en-us">
    
    <head>
      <meta charset="utf-8" />
      <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
      <title>Vector3</title>
    </head>
    
    <body>
      <p>See console</p>
      <script src="https://cdnjs.cloudflare.com/ajax/libs/msgpack5/6.0.0/msgpack5.min.js"></script>
      <!-- /build/vector3.js is a file built using emscripten -->
      <script src="https://wh8bwy.csb.app/build/vector3.js"></script>
    </body>
    
    </html>

    Here is vector3.cpp for quick reference.

    #include <emscripten.h>
    #include <msgpack.hpp>
    #include <iostream>
    
    struct Position {
      public:
        MSGPACK_DEFINE_MAP(positionId, x, y, z);
        std::string positionId;
        float x;
        float y;
        float z;
    };
    
    class Vector3 {
      public:
        float x;
        float y;
        float z;
        
        Vector3(float X, float Y, float Z){
          x = X;
          y = Y;
          z = Z;
        }
    };
    
    EMSCRIPTEN_KEEPALIVE
    extern "C" char* processVector3(char* inputPointer, int inputSize, char* outputPointer) {
      msgpack::object_handle objectHandle = msgpack::unpack(inputPointer, inputSize);
      msgpack::object object = objectHandle.get();
    
      std::vector<Position> positions;
      object.convert(positions);
    
      std::vector<std::string> positionIds;
      for (auto& position : positions) {
        // CREATE VECTOR3 OBJECTS AND DO WORK
        try {
          std::cout << "Attempting to process " << position.positionId << ": " << position.x << " " << position.y << " " << position.z << std::endl;
          Vector3 vector3(position.x, position.y, position.z);
          positionIds.push_back(position.positionId);
          std::cout << "Successfully processed " << position.positionId << ": " << vector3.x << " " << vector3.y << " " << vector3.z << std::endl;
        } catch (std::exception& e) {
          std::cout << e.what() << std::endl;
        }
      }
    
      // OUTPUT
      msgpack::sbuffer sbuf;
      msgpack::pack(sbuf, positionIds);
    
      *outputPointer = sbuf.size();
      return sbuf.data();
    }