Emscripten и компиляция C++ в WebAssembly

Ammo.js представляет собой порт физического движка Bullet Physics, написанного на C++, в среду JavaScript и WebAssembly. Исходный код Bullet компилируется при помощи инструмента Emscripten, который преобразует C++ в WebAssembly (WASM) и генерирует JavaScript-обвязку для взаимодействия с браузерной средой.

В основе процесса лежит следующая цепочка:

  1. Исходный код Bullet (C++)
  2. Компиляция через LLVM → WebAssembly
  3. Генерация glue-кода JavaScript
  4. Исполнение в браузере

Emscripten выступает в роли компилятора, рантайма и системы интеграции с веб-платформой.


Что такое Emscripten

Emscripten — это инструментальная цепочка, основанная на LLVM, предназначенная для компиляции C и C++ кода в WebAssembly и JavaScript. Она обеспечивает:

  • трансляцию C++ в WASM
  • генерацию JavaScript-интерфейсов
  • эмуляцию стандартной библиотеки
  • управление памятью
  • поддержку асинхронной загрузки
  • интеграцию с Web API

Emscripten не просто компилирует код — он создаёт полноценную среду выполнения, в которой C++-программа может работать в браузере.


WebAssembly как целевая платформа

WebAssembly — это низкоуровневый бинарный формат, предназначенный для выполнения в браузере с высокой производительностью. Он работает внутри JavaScript-движка, но исполняется отдельно от JS-кода.

Ключевые особенности WebAssembly:

  • Компилируемый бинарный формат
  • Почти нативная производительность
  • Статическая типизация
  • Управление линейной памятью
  • Работа через sandbox

Bullet Physics компилируется в WASM-модуль, который затем подключается к странице через JavaScript.


Процесс компиляции Bullet в Ammo.js

1. Исходный код

Bullet Physics — кроссплатформенный физический движок, написанный на C++. Он содержит:

  • систему динамики твёрдых тел
  • коллизии
  • constraint-систему
  • broadphase / narrowphase
  • систему памяти

Ammo.js не переписывает этот код на JavaScript, а компилирует его.


2. Компиляция через Emscripten

Процесс включает несколько стадий:

Препроцессинг и компиляция

C++ код компилируется в LLVM intermediate representation (IR).

Генерация WebAssembly

LLVM IR транслируется в WASM.

Генерация JavaScript glue-кода

Emscripten создаёт JavaScript-файл, который:

  • загружает WASM
  • управляет памятью
  • создаёт интерфейс к C++ функциям
  • реализует runtime API

Типичная команда компиляции

Пример базовой команды:

emcc bullet.cpp -O3 \
  -s WASM=1 \
  -s MODULARIZE=1 \
  -s EXPORT_NAME="Ammo" \
  -s ALLOW_MEMORY_GROWTH=1 \
  -o ammo.js

Ключевые флаги

  • -O3 — оптимизация
  • -s WASM=1 — генерация WebAssembly
  • -s MODULARIZE=1 — экспорт в виде функции
  • -s EXPORT_NAME — имя модуля
  • -s ALLOW_MEMORY_GROWTH — динамическое расширение памяти

Модель памяти WebAssembly

WebAssembly использует линейную память — один непрерывный блок байтов. В отличие от Jav * aScript:

  • нет сборщика мусора
  • нет автоматического управления памятью
  • работа через указатели

Emscripten эмулирует:

  • стек
  • heap
  • malloc/free
  • new/delete

Память представлена как ArrayBuffer, поверх которого создаются типизированные представления:

  • HEAP8
  • HEAP16
  • HEAP32
  • HEAPF32
  • HEAPF64

Связывание C++ и JavaScript

Emscripten предоставляет два механизма экспорта:

1. Экспорт функций

Через флаг:

-s EXPORTED_FUNCTIONS="['_myFunction']"

Функции становятся доступными из JavaScript.


2. Embind

Embind — система биндингов, позволяющая автоматически связывать C++ классы с JavaScript.

Пример C++ кода:

#include <emscripten/bind.h>
using namespace emscripten;

class Vec3 {
public:
    float x, y, z;
    Vec3(float x, float y, float z) : x(x), y(y), z(z) {}
};

EMSCRIPTEN_BINDINGS(my_module) {
    class_<Vec3>("Vec3")
        .constructor<float, float, float>()
        .property("x", &Vec3::x)
        .property("y", &Vec3::y)
        .property("z", &Vec3::z);
}

В Jav * aScript:

const v = new Ammo.Vec3(1, 2, 3);

Инициализация Ammo.js

Современная версия Ammo.js компилируется с флагом MODULARIZE=1, поэтому загрузка выглядит так:

Ammo().then((AmmoLib) => {
    const btVector3 = AmmoLib.btVector3;
});

Модуль инициализируется асинхронно, поскольку загрузка WASM — асинхронная операция.


Управление жизненным циклом объектов

В C++:

  • объекты создаются через new
  • удаляются через delete

В Ammo.js:

const vec = new Ammo.btVector3(1, 2, 3);
Ammo.destroy(vec);

Отсутствие вызова destroy() приводит к утечкам памяти в WASM-heap.

Emscripten не подключает сборщик мусора JavaScript к управлению C++ объектами.


Оптимизация размера сборки

Emscripten поддерживает:

  • dead code elimination
  • tree shaking
  • минимизацию runtime
  • отключение ненужных систем

Флаги:

-s MINIMAL_RUNTIME=1
-s FILESYSTEM=0
-s ASSERTIONS=0

Это уменьшает размер финального .wasm и .js.


Различия ASM.js и WebAssembly

Ранние версии Ammo.js использовали asm.js. Это был подмножество JavaScript, оптимизированное под JIT.

WebAssembly обеспечивает:

  • меньший размер
  • быстрее загрузку
  • лучшую производительность
  • строгую типизацию

Современные сборки используют WASM.


Асинхронная загрузка и streaming compilation

Браузеры поддерживают потоковую компиляцию:

WebAssembly.instantiateStreaming(fetch("ammo.wasm"));

Emscripten автоматически использует этот механизм при поддержке сервера с правильным MIME-типом:

application/wasm

Работа с памятью и указателями

При передаче данных между JS и WASM используются:

  • указатели (числовые смещения)
  • копирование в heap
  • чтение через HEAP-массивы

Пример выделения памяти:

const ptr = Ammo._malloc(12);
Ammo.HEAPF32[ptr >> 2] = 1.0;

Сдвиг >> 2 используется, поскольку Float32 занимает 4 байта.


Ограничения среды

WebAssembly:

  • не имеет прямого доступа к DOM
  • не может выполнять системные вызовы
  • не поддерживает потоки без специальных флагов
  • изолирован sandbox-моделью браузера

Emscripten реализует:

  • виртуальную файловую систему
  • таймеры
  • polyfill для стандартной библиотеки

Потоки и Pthreads

Emscripten поддерживает pthreads через Web Workers:

-s USE_PTHREADS=1

Требуется:

  • SharedArrayBuffer
  • правильные заголовки COOP/COEP

В контексте Ammo.js многопоточность используется редко из-за ограничений браузера.


Отладка

Флаги для отладки:

-g
-s ASSERTIONS=2
-s SAFE_HEAP=1

Позволяют:

  • получать stack trace
  • проверять доступ к памяти
  • видеть ошибки рантайма

Также возможно использование sourcemaps.


Интеграция с системами сборки

Emscripten совместим с:

  • CMake
  • Make
  • Ninja
  • Webpack (для финальной интеграции)

Для Bullet используется CMake с кастомным toolchain-файлом Emscripten.


Структура финального пакета Ammo.js

Обычно включает:

  • ammo.js — glue-код
  • ammo.wasm — бинарный модуль
  • иногда ammo.worker.js — для потоков

Glue-код:

  • создаёт Module
  • загружает wasm
  • инициализирует runtime
  • экспортирует API

Внутреннее устройство рантайма

Emscripten runtime содержит:

  • Module object
  • memory management
  • stack pointer
  • function table
  • syscall handlers
  • event loop integration

Он обеспечивает мост между C++ ABI и JavaScript.


Производительность и особенности

Факторы, влияющие на производительность:

  • размер heap
  • частота аллокаций
  • вызовы JS ↔︎ WASM
  • использование Embind
  • оптимизации компилятора

Вызовы между JS и WASM имеют стоимость, поэтому критические вычисления выполняются полностью внутри WASM.


Версионирование и совместимость

Совместимость зависит от:

  • версии Emscripten
  • версии LLVM
  • версии браузера
  • поддержки WebAssembly

Обновление Emscripten может менять ABI и требовать пересборки.


Причины выбора Emscripten для Ammo.js

  • отсутствие переписывания Bullet
  • сохранение производительности C++
  • перенос существующей экосистемы
  • минимальные изменения исходного кода
  • поддержка стандарта WebAssembly

Компиляция через Emscripten делает возможным использование высокопроизводительной C++ физики в среде JavaScript без переписывания алгоритмов.