Skip to content

In this approach, you'll code an interactive WebAssembly application that responds to user actions, such as moving sliders on the web page using C++! You'll also enable mouse and keyboard interaction, set up an HTML page with a canvas element, and connect this canvas to a vtkRenderWindow and vtkRenderWindowInteractor for rendering and interaction.

Write native code in C++

In the following code, a vtkConeSource generates polygonal geometry, which is rendered by a vtkPolyDataMapper. We also wrap the C++ functions void setConeResolution(int) and void setConeHeight(double) to JavaScript and connect them to separate sliders that allow adjusting the cone's resolution and height, respectively.

cpp
extern "C" {
EMSCRIPTEN_KEEPALIVE void setConeResolution(int resolution) {
  if (coneSource) {
    coneSource->SetResolution(resolution);
  } else {
    std::cerr << "Error: Cone source is not initialized." << std::endl;
  }
}
}
cpp
void setConeHeight(double height) {
  if (coneSource) {
    coneSource->SetHeight(height);
  } else {
    std::cerr << "Error: Cone source is not initialized." << std::endl;
  }
}

void render() {
  if (renderWindow) {
    renderWindow->Render();
  } else {
    std::cerr << "Error: Render window is not initialized." << std::endl;
  }
}

EMSCRIPTEN_BINDINGS(cone_module) {
  emscripten::function("setConeHeight", &setConeHeight);
  emscripten::function("render", &render);
}
cpp
int coneResolution = 6;
std::string canvasSelector = "#canvas";

for (int i = 0; i < argc; ++i) {
  if (argv[i] && !strcmp(argv[i], "--help")) {
    std::cout
        << "Usage: " << argv[0]
        << " [options]\n"
           "Options:\n"
           "  --help: Show this help message\n"
           "  --resolution: Set the resolution of the cone (default is 6)\n"
           "  --canvas-selector: Set the selector for canvas (default is "
           "#canvas)\n";
    return 0;
  } else if (argv[i] && !strcmp(argv[i], "--resolution")) {
    // Handle resolution option
    if (++i < argc) {
      coneResolution = atoi(argv[i]);
    }
  } else if (argv[i] && !strcmp(argv[i], "--canvas-selector")) {
    if (++i < argc) {
      canvasSelector = argv[i];
    }
  }
}
cpp
// Create pipeline
coneSource = vtkConeSource::New();
coneSource->SetResolution(coneResolution);

vtkNew<vtkPolyDataMapper> mapper;
mapper->SetInputConnection(coneSource->GetOutputPort());

vtkNew<vtkActor> actor;
actor->SetMapper(mapper);
actor->GetProperty()->SetEdgeVisibility(1);
actor->GetProperty()->SetEdgeColor(1, 0, 1);

// Create a renderer, render window, and interactor
vtkNew<vtkRenderer> renderer;
renderer->SetBackground(0.2, 0.3, 0.4);
renderer->AddActor(actor);

renderWindow = vtkRenderWindow::New();
renderWindow->AddRenderer(renderer);

vtkNew<vtkRenderWindowInteractor> renderWindowInteractor;
renderWindowInteractor->SetRenderWindow(renderWindow);
renderer->ResetCamera();
cpp
if (auto *wasmInteractor = vtkWebAssemblyRenderWindowInteractor::SafeDownCast(
        renderWindowInteractor)) {
  // If using WebAssembly, set the canvas selector
  wasmInteractor->SetCanvasSelector(canvasSelector.c_str());
}

// If your canvas has id="canvas", this if block is not needed.
// you can set it here if your canvas has a different id.
if (auto *webGLRenderWindow =
        vtkWebAssemblyOpenGLRenderWindow::SafeDownCast(renderWindow)) {
  // If using WebAssembly, set the canvas selector
  webGLRenderWindow->SetCanvasSelector(canvasSelector.c_str());
} else if (auto *webGPURenderWindow =
               vtkWebAssemblyWebGPURenderWindow::SafeDownCast(renderWindow)) {
  // If using WebGPU, set the canvas selector
  webGPURenderWindow->SetCanvasSelector(canvasSelector.c_str());
} else {
  // If not using WebAssembly or WebGPU, print an error message
  std::cerr << "Error: Unsupported render window type. "
            << "Please use a WebAssembly or WebGPU render window."
            << std::endl;
  return EXIT_FAILURE;
}
cpp
// Clean up the cone source when the interactor is deleted
vtkNew<vtkCallbackCommand> onDeleteCallback;
onDeleteCallback->SetCallback(
    [](vtkObject *caller, unsigned long, void *, void *) {
      if (coneSource != nullptr) {
        coneSource->Delete();
        coneSource = nullptr;
      }
      if (renderWindow != nullptr) {
        renderWindow->Delete();
        renderWindow = nullptr;
      }
    });
renderWindowInteractor->AddObserver(vtkCommand::DeleteEvent,
                                    onDeleteCallback);
cpp
// Start event loop
renderWindow->Render();
renderWindowInteractor->Start();
cpp
#include "vtkActor.h"
#include "vtkCallbackCommand.h"
#include "vtkConeSource.h"
#include "vtkNew.h"
#include "vtkPolyData.h"
#include "vtkPolyDataMapper.h"
#include "vtkProperty.h"
#include "vtkRenderWindow.h"
#include "vtkRenderWindowInteractor.h"
#include "vtkRenderer.h"

#include "vtkWebAssemblyOpenGLRenderWindow.h"
#include "vtkWebAssemblyRenderWindowInteractor.h"
#include "vtkWebAssemblyWebGPURenderWindow.h"

#include <cstdlib>
#include <cstring>
#include <iostream>
#include <string>

#include <emscripten/bind.h>

namespace {
vtkConeSource *coneSource = nullptr;
vtkRenderWindow *renderWindow = nullptr;
} // namespace

// region emscriptenKeepalive
extern "C" {
EMSCRIPTEN_KEEPALIVE void setConeResolution(int resolution) {
  if (coneSource) {
    coneSource->SetResolution(resolution);
  } else {
    std::cerr << "Error: Cone source is not initialized." << std::endl;
  }
}
}
// endregion emscriptenKeepalive

// region embindFunctions
void setConeHeight(double height) {
  if (coneSource) {
    coneSource->SetHeight(height);
  } else {
    std::cerr << "Error: Cone source is not initialized." << std::endl;
  }
}

void render() {
  if (renderWindow) {
    renderWindow->Render();
  } else {
    std::cerr << "Error: Render window is not initialized." << std::endl;
  }
}

EMSCRIPTEN_BINDINGS(cone_module) {
  emscripten::function("setConeHeight", &setConeHeight);
  emscripten::function("render", &render);
}
// endregion embindFunctions

int main(int argc, char *argv[]) {
  // region args
  int coneResolution = 6;
  std::string canvasSelector = "#canvas";

  for (int i = 0; i < argc; ++i) {
    if (argv[i] && !strcmp(argv[i], "--help")) {
      std::cout
          << "Usage: " << argv[0]
          << " [options]\n"
             "Options:\n"
             "  --help: Show this help message\n"
             "  --resolution: Set the resolution of the cone (default is 6)\n"
             "  --canvas-selector: Set the selector for canvas (default is "
             "#canvas)\n";
      return 0;
    } else if (argv[i] && !strcmp(argv[i], "--resolution")) {
      // Handle resolution option
      if (++i < argc) {
        coneResolution = atoi(argv[i]);
      }
    } else if (argv[i] && !strcmp(argv[i], "--canvas-selector")) {
      if (++i < argc) {
        canvasSelector = argv[i];
      }
    }
  }
  // endregion args
  // region vtk
  // Create pipeline
  coneSource = vtkConeSource::New();
  coneSource->SetResolution(coneResolution);

  vtkNew<vtkPolyDataMapper> mapper;
  mapper->SetInputConnection(coneSource->GetOutputPort());

  vtkNew<vtkActor> actor;
  actor->SetMapper(mapper);
  actor->GetProperty()->SetEdgeVisibility(1);
  actor->GetProperty()->SetEdgeColor(1, 0, 1);

  // Create a renderer, render window, and interactor
  vtkNew<vtkRenderer> renderer;
  renderer->SetBackground(0.2, 0.3, 0.4);
  renderer->AddActor(actor);

  renderWindow = vtkRenderWindow::New();
  renderWindow->AddRenderer(renderer);

  vtkNew<vtkRenderWindowInteractor> renderWindowInteractor;
  renderWindowInteractor->SetRenderWindow(renderWindow);
  renderer->ResetCamera();
  // endregion vtk
  // region bindCanvas
  if (auto *wasmInteractor = vtkWebAssemblyRenderWindowInteractor::SafeDownCast(
          renderWindowInteractor)) {
    // If using WebAssembly, set the canvas selector
    wasmInteractor->SetCanvasSelector(canvasSelector.c_str());
  }

  // If your canvas has id="canvas", this if block is not needed.
  // you can set it here if your canvas has a different id.
  if (auto *webGLRenderWindow =
          vtkWebAssemblyOpenGLRenderWindow::SafeDownCast(renderWindow)) {
    // If using WebAssembly, set the canvas selector
    webGLRenderWindow->SetCanvasSelector(canvasSelector.c_str());
  } else if (auto *webGPURenderWindow =
                 vtkWebAssemblyWebGPURenderWindow::SafeDownCast(renderWindow)) {
    // If using WebGPU, set the canvas selector
    webGPURenderWindow->SetCanvasSelector(canvasSelector.c_str());
  } else {
    // If not using WebAssembly or WebGPU, print an error message
    std::cerr << "Error: Unsupported render window type. "
              << "Please use a WebAssembly or WebGPU render window."
              << std::endl;
    return EXIT_FAILURE;
  }
  // endregion bindCanvas
  // region coneSourceLifecycle
  // Clean up the cone source when the interactor is deleted
  vtkNew<vtkCallbackCommand> onDeleteCallback;
  onDeleteCallback->SetCallback(
      [](vtkObject *caller, unsigned long, void *, void *) {
        if (coneSource != nullptr) {
          coneSource->Delete();
          coneSource = nullptr;
        }
        if (renderWindow != nullptr) {
          renderWindow->Delete();
          renderWindow = nullptr;
        }
      });
  renderWindowInteractor->AddObserver(vtkCommand::DeleteEvent,
                                      onDeleteCallback);
  // endregion coneSourceLifecycle
  // region interactor
  // Start event loop
  renderWindow->Render();
  renderWindowInteractor->Start();
  // endregion interactor
  return EXIT_SUCCESS;
}

Explaination: The referenced sections from ../../../examples/cpp/cone/main.cpp demonstrate the following:

  • Directly expose C functions

    Native functions which take or return simple C data types can be wrapped conveniently with an EMSCRIPTEN_KEEPALIVE macro. This macro makes the function available in JavaScript with a prefix _. Here, a C symbol setConeResolution becomes _setConeResolution.

    WARNING

    Symbols that are wrapped using EMSCRIPTEN_KEEPALIVE must have C-style linkage (extern "C"), otherwise the symbol name will be mangled like __Z17setConeResolutioni which is no fun!

  • Bind C++ functions with embind

    You can wrap any C++ function to JavaScript using the emscripten::function construct. Member functions of classes can be wrapped with emscripten::class. Emscripten does the to and fro conversion for methods that take or return complex types like std::string. You can learn more about this style of wrapping at emscripten.org/docs.

  • Argument parsing

    Command-line arguments allow configuration of the application at startup. This is similar to how you would parse arguments in desktop applications. In WASM, arguments are passed via the arguments key to the wasm module object.

  • VTK setup

    Initialize the VTK pipeline by creating a cone source, mapper, actor, renderer, and render window.

  • Bind canvas

    Connect the HTML canvas element to the VTK render window. This enables rendering output in the browser.

    DANGER

    A number of things can go wrong in this step. You will see an error in the console if the binding is performed too late (after a VTK render call), or when the canvas selector has a spelling mistake.

    js
    vtkWebAssemblyOpenGLRenderWindow (0x1d3c80): Error (0) initializing WebGL2.
    vtkWebAssemblyOpenGLRenderWindow (0x1d3c80): Failed to create Emscripten opengl context Uncaught (in promise) TypeError: Cannot read properties of undefined (reading 'getParameter')
    at emscriptenWebGLGet (main.js:1:12409402)
    at _glGetIntegerv (main.js:1:12411165)
    at 023050d2:0xc1103
    at 023050d2:0x4d5c4b
    at 023050d2:0x4a73b9
    at 023050d2:0x17d647
    at 023050d2:0x4d3d4a
    at 023050d2:0x7108c8
    at callMain (main.js:1:12453949)
    at doRun (main.js:1:12454368)
  • Cleanup globals

    We ensure that global objects are cleaned up when the interactor gets deleted by doing cleanup in a callback function that observes the DeleteEvent on the render window interactor. This application uses globals so that wrapped functions like setConeResolution and render can access the vtkConeSource object and vtkRenderWindow object respectively.

  • Start interactor

    Sets up and starts the VTK render window interactor, enabling mouse and keyboard interaction with the rendered scene.

The full code combines these steps to create a minimal interactive 3D visualization in the browser using VTK, C++, and Embind.

Interface to the web with HTML

The HTML provides a canvas with id "vtk-wasm-canvas". It also passes the resolution of cone and the canvas selector argument strings to the main wasm program using the arguments key in the Module dictionary. The main.js script is generated by emscripten. It checks whether a variable named Module is defined in the global scope and imports the configuration like program arguments, canvas, etc from the Module object. UI callbacks can access the wasm module using the Module variable.

html
<!doctype html>
<html>

<head>
  <meta charset="utf-8" />
</head>

<body>
  <div>
    <input type="range" min="3" max="128" value="8" onchange="Module._setConeResolution(this.value); Module.render();"
      style="position: absolute; top: 1rem; right: 1rem; z-index: 10;" />
    <input type="range" min="1.0" max="2.0" value="1.0" step="0.1" onchange="Module.setConeHeight(this.value); Module.render();"
      style="position: absolute; top: 1rem; left: 1rem; z-index: 10;" />
  </div>
  <canvas id="vtk-wasm-canvas" tabindex="-1" onclick="focus()"></canvas>
  <script type="text/javascript">
    var Module = {
      arguments: [
        "--resolution", "8",
        "--canvas-selector", "#vtk-wasm-canvas"],
      canvas: () => {
        return document.querySelector("#vtk-wasm-canvas");
      },
    };
  </script>
  <script type="text/javascript" src="./main.js"></script>
</body>

</html>

TIP

You can also use the -sMODULARIZE + -sEXPORT_NAME=createModule emscripten link options which offer a different approach to instantiating the wasm runtime. That approach lets you call a function createModule({...options}) that returns a Promise resolving with a handle to the wasm runtime. See emscripten.org/docs for more information.

Build system

Finally, the CMake code creates a WASM executable by compiling the C++ source code and linking with VTK libraries.

cmake
cmake_minimum_required(VERSION 3.29)
project(cone_main LANGUAGES C CXX)

# -----------------------------------------------------------------------------
# EMSCRIPTEN only
# -----------------------------------------------------------------------------

if (NOT EMSCRIPTEN)
  message("Skipping example: This needs to run inside an Emscripten build environment")
  return ()
endif ()

# -----------------------------------------------------------------------------
# Handle VTK dependency
# -----------------------------------------------------------------------------

find_package(VTK REQUIRED)

if (NOT VTK_FOUND)
  message("Skipping example: ${VTK_NOT_FOUND_MESSAGE}")
  return ()
endif ()

# -----------------------------------------------------------------------------
# Compile example code
# -----------------------------------------------------------------------------

add_executable(main main.cpp)
target_link_libraries(main PRIVATE ${VTK_LIBRARIES})

# -----------------------------------------------------------------------------
# Optimizations
# -----------------------------------------------------------------------------

set(emscripten_optimizations)
set(emscripten_debug_options)

# Set a default build type if none was specified
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
  message(STATUS "Setting build type to 'Debug' as none was specified.")
  set(CMAKE_BUILD_TYPE Debug CACHE STRING "Choose the type of build." FORCE)
  # Set the possible values of build type for cmake-gui
  set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS "Debug" "Release"
    "MinSizeRel" "RelWithDebInfo")
endif()

if (CMAKE_BUILD_TYPE STREQUAL "Release")
  set(wasm_optimize "BEST")
  set(wasm_debuginfo "NONE")
elseif (CMAKE_BUILD_TYPE STREQUAL "MinSizeRel")
  set(wasm_optimize "SMALLEST_WITH_CLOSURE")
  set(wasm_debuginfo "NONE")
elseif (CMAKE_BUILD_TYPE STREQUAL "RelWithDebInfo")
  set(wasm_optimize "MORE")
  set(wasm_debuginfo "PROFILE")
elseif (CMAKE_BUILD_TYPE STREQUAL "Debug")
  set(wasm_optimize "NO_OPTIMIZATION")
  set(wasm_debuginfo "DEBUG_NATIVE")
endif ()
set(wasm_optimize_NO_OPTIMIZATION "-O0")
set(wasm_optimize_LITTLE "-O1")
set(wasm_optimize_MORE "-O2")
set(wasm_optimize_BEST "-O3")
set(wasm_optimize_SMALLEST "-Os")
set(wasm_optimize_SMALLEST_WITH_CLOSURE "-Oz")
set(wasm_optimize_SMALLEST_WITH_CLOSURE_link "--closure=1")

set(emscripten_link_options
  "-lembind"
  "-sALLOW_MEMORY_GROWTH=1"
  "-sSINGLE_FILE=1")

if (DEFINED "wasm_optimize_${wasm_optimize}")
  list(APPEND emscripten_optimizations
    ${wasm_optimize_${wasm_optimize}})
  list(APPEND emscripten_link_options
    ${wasm_optimize_${wasm_optimize}_link})
else ()
  message (FATAL_ERROR "Unrecognized value for wasm_optimize=${wasm_optimize}")
endif ()

set(wasm_debuginfo_NONE "-g0")
set(wasm_debuginfo_READABLE_JS "-g1")
set(wasm_debuginfo_PROFILE "-g2")
set(wasm_debuginfo_DEBUG_NATIVE "-g3")
set(wasm_debuginfo_DEBUG_NATIVE_link "-sASSERTIONS=1")
if (DEFINED "wasm_debuginfo_${wasm_debuginfo}")
  list(APPEND emscripten_debug_options
    ${wasm_debuginfo_${wasm_debuginfo}})
  list(APPEND emscripten_link_options
    ${wasm_debuginfo_${wasm_debuginfo}_link})
else ()
  message (FATAL_ERROR "Unrecognized value for wasm_debuginfo=${wasm_debuginfo}")
endif ()

target_compile_options(main
  PRIVATE
    ${emscripten_compile_options}
    ${emscripten_optimizations}
    ${emscripten_debug_options})
target_link_options(main
  PRIVATE
    ${emscripten_link_options}
    ${emscripten_optimizations}
    ${emscripten_debug_options})

set(CMAKE_NINJA_FORCE_RESPONSE_FILE "ON" CACHE BOOL "Force Ninja to use response files.")

# -----------------------------------------------------------------------------
# VTK modules initialization
# -----------------------------------------------------------------------------

vtk_module_autoinit(
  TARGETS  main
  MODULES  ${VTK_LIBRARIES}
)

# Output the main.js file to the docs/public/demo/cpp-app-1 directory
# so that it can be used in the documentation.
set_target_properties(main PROPERTIES
  RUNTIME_OUTPUT_DIRECTORY "${CMAKE_SOURCE_DIR}/../../../docs/public/demo/cpp-app-1"
)

Result