Туторіал · WebAssembly · C++ · Продуктивність
📅 Липень 2026 ⏱ ≈ 35 хв 🎯 Просунутий

WebAssembly + C++ для фізичного рушія у браузері

Фізичні рушії на JavaScript впираються у стіну приблизно на кількох тисячах тіл: паузи збирача сміття, «упаковані» числа та деоптимізації JIT — усе це конфліктує з жорсткими числовими циклами. Компіляція фізичного ядра на C++ у WebAssembly дає майже нативну пропускну здатність і передбачуваний час кадру — цей туторіал будує таке ядро з нуля через Emscripten і підключає його до сцени Three.js.

1. Чому WebAssembly для фізики

Крок фізики — це жорсткий числовий цикл, що виконується 60+ разів на секунду: інтегрування, broad phase, narrow phase, розв'язання. Рушії JavaScript добре JIT-компілюють гарячі цикли, але три речі все одно заважають на масштабі:

WebAssembly — це стековий бінарний формат інструкцій, наперед скомпільований у майже нативний машинний код, з плоским буфером лінійної пам'яті, яким ви явно керуєте — без GC, без прихованих класів, передбачувана поведінка кешу. Він не переможе відточений SIMD-JavaScript тривіально, але усуває найгіршу хвостову затримку, через яку фізика здається «рваною».

Коли це не варто: для кількох сотень тіл або простої демонстрації частинок чистий JavaScript (або рушій на JS з нуля) простіше налагодити і швидше запустити у продакшн. Переходьте на WASM, коли профілювання показує, що вузьким місцем є саме крок фізики — не рендеринг.

2. Налаштування тулчейну з Emscripten

Emscripten компілює C/C++ у WebAssembly плюс JS-файл «клею», що завантажує і ініціалізує модуль.

# Одноразове налаштування
git clone https://github.com/emscripten-core/emsdk.git
cd emsdk
./emsdk install latest
./emsdk activate latest
source ./emsdk_env.sh

# Компіляція physics.cpp у WASM-модуль + JS-завантажувач
emcc physics.cpp -O3 \
  -lembind \
  -s MODULARIZE=1 \
  -s EXPORT_ES6=1 \
  -s ALLOW_MEMORY_GROWTH=1 \
  -s ENVIRONMENT=web \
  -o physics.js
Прапорець Ефект
-O3 Агресивна оптимізація — необхідна для гарячого циклу фізики
-lembind Приєднує рантайм embind для відкриття C++ класів для JS
-s MODULARIZE=1 -s EXPORT_ES6=1 Генерує ES-модуль-фабрику замість глобальної змінної
-s ALLOW_MEMORY_GROWTH=1 Дозволяє купі WASM зростати понад початковий розмір під час виконання

3. Мінімальне фізичне ядро на C++

Зберігайте тіла як struct-of-arrays (SoA), а не масив структур — це тримає дані позиції/швидкості суміжними і кеш-дружніми, і напряму мапиться на плоскі типізовані масиви на боці JS.

// physics.cpp
#include <vector>
#include <cmath>

struct World {
  std::vector<float> px, py, pz;   // позиції
  std::vector<float> vx, vy, vz;   // швидкості
  std::vector<float> invMass;
  float gravity = -9.81f;

  int addBody(float x, float y, float z, float mass) {
    px.push_back(x); py.push_back(y); pz.push_back(z);
    vx.push_back(0); vy.push_back(0); vz.push_back(0);
    invMass.push_back(mass > 0 ? 1.0f / mass : 0.0f);
    return (int)px.size() - 1;
  }

  void step(float dt) {
    size_t n = px.size();
    for (size_t i = 0; i < n; ++i) {
      if (invMass[i] == 0.0f) continue; // статичне тіло
      vy[i] += gravity * dt;
      px[i] += vx[i] * dt;
      py[i] += vy[i] * dt;
      pz[i] += vz[i] * dt;
      // Колізія з підлогою при y=0
      if (py[i] < 0.0f) { py[i] = 0.0f; vy[i] *= -0.5f; }
    }
  }

  // Вказівник на початок буфера позицій — для передачі без копіювання
  uintptr_t positionsPtr() { return (uintptr_t)px.data(); }
  int count() const { return (int)px.size(); }
};
SoA проти AoS: масив структур (vector<Body>) писати простіше, але він розкидає позицію/швидкість/масу по пам'яті для кожного тіла. SoA тримає кожну координату x суміжно — краще і для кеш-ліній CPU, і для відкриття плоских типізованих масивів-в'ю для JavaScript.

4. Прив'язка через embind

embind генерує JS ↔ C++ «клей» автоматично — для простих викликів методів ручна арифметика вказівників не потрібна.

// physics.cpp — додати в кінці
#include <emscripten/bind.h>
using namespace emscripten;

EMSCRIPTEN_BINDINGS(physics_module) {
  class_<World>("World")
    .constructor<>()
    .function("addBody", &World::addBody)
    .function("step", &World::step)
    .function("positionsPtr", &World::positionsPtr)
    .function("count", &World::count);
}
// main.js — завантажити і використати модуль
import createPhysicsModule from './physics.js';

const Module = await createPhysicsModule();
const world = new Module.World();

for (let i = 0; i < 5000; i++) {
  world.addBody(Math.random() * 10, 5 + Math.random() * 10, Math.random() * 10, 1.0);
}

function animate() {
  requestAnimationFrame(animate);
  world.step(1 / 60);
  renderer.render(scene, camera);
}

5. Передача даних без копіювання через лінійну пам'ять

Виклик embind-обгорнутого геттера на тіло щокадру (наприклад, один JS-виклик для читання x кожного тіла) знову вносить накладні витрати межі виклику 5000+ разів за кадр. Натомість зчитуйте сиру лінійну пам'ять WASM напряму як типізований масив — без копій, без викликів на кожне тіло:

// Float32Array-в'ю напряму над купою WASM — без копії!
function getPositionsView(Module, world, count) {
  const ptr = world.positionsPtr();
  return new Float32Array(Module.HEAPF32.buffer, ptr, count);
}

const positions = getPositionsView(Module, world, 5000);
// positions[i] тепер читає координату x тіла i напряму з пам'яті C++
// пере-нарізайте після world.step() лише якщо ALLOW_MEMORY_GROWTH перевиділив купу
Слідкуйте за від'єднаними буферами: коли купа WASM зростає (ALLOW_MEMORY_GROWTH=1), базовий ArrayBuffer замінюється, і будь-яке раніше створене в'ю Float32Array стає застарілим/від'єднаним. Перестворюйте в'ю після будь-якої операції, що може спричинити зростання, або наперед резервуйте достатню ємність у C++ (vector::reserve), щоб повністю уникнути зростання під час стабільної симуляції.

6. Керування сценою Three.js зі стану WASM

Маючи стабільне в'ю Float32Array над купою WASM, подавайте його напряму у InstancedMesh без жодного виділення JS-об'єкта на тіло:

const geometry = new THREE.SphereGeometry(0.2, 8, 8);
const material = new THREE.MeshStandardMaterial({ color: 0x60a5fa });
const mesh = new THREE.InstancedMesh(geometry, material, 5000);
scene.add(mesh);

const dummy = new THREE.Object3D();

function syncMeshFromWasm() {
  const n = world.count();
  const ptr = world.positionsPtr();
  const px = new Float32Array(Module.HEAPF32.buffer, ptr, n);
  // (у цьому розташуванні py/pz зберігаються в окремих векторах — відкрийте і їхні вказівники)
  for (let i = 0; i < n; i++) {
    dummy.position.set(px[i], py[i], pz[i]);
    dummy.updateMatrix();
    mesh.setMatrixAt(i, dummy.matrix);
  }
  mesh.instanceMatrix.needsUpdate = true;
}

function animate() {
  requestAnimationFrame(animate);
  world.step(1 / 60);
  syncMeshFromWasm();
  renderer.render(scene, camera);
}

7. Потоки: pthreads і SharedArrayBuffer

Навіть на нативній швидкості велика симуляція все ще може перевищити бюджет одного кадру на одному потоці. Emscripten може скомпілювати std::thread/pthreads у справжні Web Workers на основі SharedArrayBuffer, дозволяючи кроку фізики виконуватись поза основним потоком:

emcc physics.cpp -O3 -lembind \
  -pthread -s PTHREAD_POOL_SIZE=4 \
  -s SHARED_MEMORY=1 \
  -s MODULARIZE=1 -s EXPORT_ES6=1 \
  -o physics.js
Потрібна крос-доменна ізоляція: SharedArrayBuffer працює лише коли сторінка віддається із заголовками Cross-Origin-Opener-Policy: same-origin і Cross-Origin-Embedder-Policy: require-corp — відсутність цих заголовків мовчки вимикає багатопоточність, і Emscripten відкочується до однопоточної збірки під час виконання.

Типова архітектура: фізичний World::step() виконується всередині воркера на спільному буфері лінійної пам'яті; основний потік зчитує в'ю Float32Array того ж буфера кожен кадр рендеру — серіалізація повідомлень не потрібна для гарячого шляху, лише невелике сповіщення «крок завершено».

8. Бенчмарк: WASM проти чистого JavaScript

Приблизні цифри для кроку N вільно падаючих сфер лише з колізією з підлогою (без broad/narrow phase між тілами), виміряні на ноутбуці середнього класу:

Тіл Чистий JS (сер. крок) WASM (сер. крок) Прискорення
500 0.15 мс 0.06 мс ≈2.5×
5 000 1.9 мс 0.5 мс ≈3.8×
50 000 21 мс 4.2 мс ≈5×

Розрив зростає з кількістю тіл з двох причин: плоска SoA-пам'ять WASM тримає цикл кеш-дружнім на масштабі, а виділення пам'яті на кожен кадр у JS спричиняють частіші (і довші) паузи GC у міру зростання купи. Реальна перевага — не лише середній час кроку, а зменшення найгірших сплесків часу кадру, що спричиняють видиме «заїкання».

Наступні кроки: додайте справжню broad phase (див. туторіал про рушій з нуля для AABB sweep-and-prune) і відкрийте її через той же патерн embind, або замініть саморобне ядро на WASM-збірку Rapier — офіційного фізичного рушія Rust→WASM, що використовує саме цю стратегію передачі без копіювання.

Часті запитання

Чого я навчуся в цьому уроці?

Скомпілюйте фізичний рушій твердих тіл на C++ у WebAssembly через Emscripten: розташування пам'яті, embind-прив'язки, передача через SharedArrayBuffer до JS/Three.js, і продуктивність порівняно з чистим JavaScript.

Які теми розглядаються в цьому уроці?

Цей урок охоплює такі теми: Чому WebAssembly для фізики, Налаштування тулчейну з Emscripten, Мінімальне фізичне ядро на C++, Прив'язка через embind, Передача даних без копіювання через лінійну пам'ять, Керування сценою Three.js зі стану WASM, Потоки: pthreads і SharedArrayBuffer, Бенчмарк: WASM проти чистого JavaScript.

Скільки часу займає цей урок?

Цей урок займає приблизно 35 хв.

Які попередні знання потрібні?

Це урок рівня «Просунутий» — окрема попередня підготовка, крім базового JavaScript, не потрібна.