WebAssembly + C++ для фізичного рушія у браузері
Фізичні рушії на JavaScript впираються у стіну приблизно на кількох тисячах тіл: паузи збирача сміття, «упаковані» числа та деоптимізації JIT — усе це конфліктує з жорсткими числовими циклами. Компіляція фізичного ядра на C++ у WebAssembly дає майже нативну пропускну здатність і передбачуваний час кадру — цей туторіал будує таке ядро з нуля через Emscripten і підключає його до сцени Three.js.
1. Чому WebAssembly для фізики
Крок фізики — це жорсткий числовий цикл, що виконується 60+ разів на секунду: інтегрування, broad phase, narrow phase, розв'язання. Рушії JavaScript добре JIT-компілюють гарячі цикли, але три речі все одно заважають на масштабі:
-
Паузи збирача сміття: виділення об'єкта
{x, y, z}на кожен контакт щокадру створює величезне навантаження на GC; пауза посеред кадру — це пропущений кадр. - Упаковані double і приховані класи: V8 має перевіряти зміну типу на кожному доступі до властивості, якщо ваші об'єкти не ідеально мономорфні.
- Немає ручного контролю розташування пам'яті: неможливо гарантувати SoA-розташування, яке тримає гарячі дані кеш-дружніми.
WebAssembly — це стековий бінарний формат інструкцій, наперед скомпільований у майже нативний машинний код, з плоским буфером лінійної пам'яті, яким ви явно керуєте — без GC, без прихованих класів, передбачувана поведінка кешу. Він не переможе відточений SIMD-JavaScript тривіально, але усуває найгіршу хвостову затримку, через яку фізика здається «рваною».
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(); }
};
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 перевиділив купу
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 у міру зростання купи. Реальна перевага — не лише середній час кроку, а зменшення найгірших сплесків часу кадру, що спричиняють видиме «заїкання».
Часті запитання
Чого я навчуся в цьому уроці?
Скомпілюйте фізичний рушій твердих тіл на C++ у WebAssembly через Emscripten: розташування пам'яті, embind-прив'язки, передача через SharedArrayBuffer до JS/Three.js, і продуктивність порівняно з чистим JavaScript.
Які теми розглядаються в цьому уроці?
Цей урок охоплює такі теми: Чому WebAssembly для фізики, Налаштування тулчейну з Emscripten, Мінімальне фізичне ядро на C++, Прив'язка через embind, Передача даних без копіювання через лінійну пам'ять, Керування сценою Three.js зі стану WASM, Потоки: pthreads і SharedArrayBuffer, Бенчмарк: WASM проти чистого JavaScript.
Скільки часу займає цей урок?
Цей урок займає приблизно 35 хв.
Які попередні знання потрібні?
Це урок рівня «Просунутий» — окрема попередня підготовка, крім базового JavaScript, не потрібна.