Проблема великих фізичних циклів на JS
V8 і SpiderMonkey — чудові JIT-компілятори, але вони йдуть на компроміси, які шкодять фізичному коду: переходи прихованих класів через динамічний запис властивостей, паузи GC кожні 50–200 мс і масиви Float64, які рідко бувають настільки дружніми до кешу, як суцільні структури-масиви (struct-of-arrays) у C.
Для симуляції тканини з 10 000 пар обмежень, що оновлюються 60 разів на секунду, ці компроміси мають значення. Бюджет основного потоку на фізику становить приблизно 4 мс на кадр (залишаючи 12 мс на рендеринг при 60 fps у межах бюджету 16,6 мс). У JS 10 000 обмежень займають ~7 мс. У WASM із SIMD: ~1,2 мс.
Конвеєр компіляції: Rust → WASM
Rust — найкращий вибір для нового фізичного коду на WASM:
безкоштовні абстракції (zero-cost), відсутність GC, чудовий
інструментарій wasm-pack і опційні SIMD-інтринзики
через std::arch. Мінімальне налаштування виглядає так:
[package] name = "physics_core" version = "0.1.0" edition = "2021" [lib] crate-type = ["cdylib"] [dependencies] wasm-bindgen = "0.2" [profile.release] opt-level = 3 lto = true codegen-units = 1
use wasm_bindgen::prelude::*;
/// Виконує одну ітерацію релаксації обмежень над плоским буфером F32.
/// positions: [x0,y0,z0, x1,y1,z1, ...] (3 float на частинку)
/// constraints: [idxA, idxB, rest_len, stiffness, ...] (4 float на обмеження)
#[wasm_bindgen]
pub fn relax_constraints(
positions: &mut [f32],
constraints: &[f32],
) {
let n = constraints.len() / 4;
for i in 0..n {
let base = i * 4;
let a = constraints[base + 0] as usize * 3;
let b = constraints[base + 1] as usize * 3;
let rest = constraints[base + 2];
let k = constraints[base + 3];
let dx = positions[b] - positions[a];
let dy = positions[b+1] - positions[a+1];
let dz = positions[b+2] - positions[a+2];
let len = (dx*dx + dy*dy + dz*dz).sqrt();
if len < 1e-9 { continue; }
let correction = (len - rest) / len * k * 0.5;
positions[a] += dx * correction;
positions[a+1] += dy * correction;
positions[a+2] += dz * correction;
positions[b] -= dx * correction;
positions[b+1] -= dy * correction;
positions[b+2] -= dz * correction;
}
}
Компіляція та збірка:
wasm-pack build --target web --release
Спільні буфери без копіювання з JS
Найважливіша оптимізація при виклику WASM з JS —
ніколи не копіювати дані. Виділіть один
Float32Array, що спирається на лінійну пам'ять WASM,
і дозвольте і JS (для рендерингу), і WASM (для фізики) працювати з
тими самими байтами.
import init, { relax_constraints } from './physics_core.js';
let wasmMemory, positions, constraints;
async function setup() {
const wasm = await init();
// Виділяємо буфери всередині лінійної пам'яті WASM
const particleCount = 1024;
const constraintCount = 2048;
const posPtr = wasm.__wbindgen_malloc(particleCount * 3 * 4); // 4 байти/f32
const conPtr = wasm.__wbindgen_malloc(constraintCount * 4 * 4);
wasmMemory = wasm.memory;
positions = new Float32Array(wasmMemory.buffer, posPtr, particleCount * 3);
constraints = new Float32Array(wasmMemory.buffer, conPtr, constraintCount * 4);
// ... заповнюємо positions і constraints ...
}
function physicsStep() {
// WASM читає/пише той самий буфер, який JS використовує для InstancedMesh у Three.js
relax_constraints(positions, constraints);
// geometry.attributes.position.array у Three.js — ТОЙ САМИЙ ArrayBuffer
// — копіювання не потрібне, просто позначаємо як «брудний»
geometry.attributes.position.needsUpdate = true;
}
Критично важливо: Після
wasm.__wbindgen_malloc посилання на
wasmMemory.buffer може стати застарілим, якщо WASM
збільшить свою пам'ять. Перегортайте Float32Array
всередині try/catch або виявляйте зростання за
допомогою хука на кшталт ResizeObserver. На практиці
найкраще заздалегідь виділити максимум, який вам колись
знадобиться, щоб узагалі уникнути зростання.
Бенчмарки: JS проти WASM проти WASM+SIMD
| Симуляція | Чистий JS (мс/кадр) | WASM (мс/кадр) | WASM+SIMD (мс/кадр) | Пришвидшення |
|---|---|---|---|---|
| Тканина (10 тис. обмежень, 8 ітерацій) | 6.8 | 2.1 | 1.1 | 6.2× |
| SPH-рідина (2 тис. частинок) | 11.4 | 3.6 | 1.9 | 6.0× |
| Реакція-дифузія (сітка 512×512) | 4.2 | 1.8 | 0.9 | 4.7× |
| N-тіл гравітація (1 тис. тіл) | 5.1 | 2.0 | 1.3 | 3.9× |
| Подвійний маятник (один ланцюг) | 0.04 | 0.04 | 0.04 | 1.0× — без переваги |
Останній рядок — ключовий урок: WASM допомагає лише тоді, коли фізичний цикл є вузьким місцем. Подвійний маятник, що розв'язує два ОДР на кадр, коштує 0,04 мс у чистому JS — додавання накладних витрат на компіляцію WASM не дає нічого.
Коли варто (і не варто) використовувати WASM?
- Фізичний цикл займає >3 мс/кадр у JS
- У вас >5000 частинок або обмежень
- Потрібна детермінована крос-платформна арифметика
- Ви портуєте наявний розв'язувач на C/C++/Rust
- Застосовний SIMD (релаксація f32×4 SIMD)
- Крок фізики вже <1 мс у JS
- Вузьке місце — GPU (сортування, рендеринг), а не CPU
- У вас немає інструментарію для збірки WASM
- Логіка сильно розгалужена або орієнтована на рядки
- Потрібні часті переходи туди-сюди між JS і WASM
Альтернатива: C/C++ через Emscripten
Якщо ви портуєте наявну фізичну бібліотеку (Bullet, Box2D,
Chipmunk), Emscripten компілює C/C++ у WASM з мінімальними змінами
коду. Робочий процес схожий — компіляція в .wasm + JS
файл-«клей» — але дає більші пакети. Повна збірка Bullet важить
~2 МБ у стисненому вигляді порівняно з ~30 КБ для написаного вручну
крейта на Rust. Використовуйте Emscripten для портування; Rust — для
нового коду, специфічного для симуляції.
WASM + Web Workers: повний стек
Для найкращих результатів запускайте фізичний модуль WASM усередині
Web Worker і використовуйте SharedArrayBuffer, щоб
ділитися позиціями частинок з рендерером Three.js в основному
потоці. Це тримає обидва потоки повністю завантаженими:
- Worker: виконує фізику WASM на фіксованій частоті 120 Гц
-
Основний потік: читає
SharedArrayBufferна кожному кадрі анімації, завантажує на GPU, рендерить -
Без накладних витрат на серіалізацію через
postMessage— буфер сам є каналом комунікації
Зауваження: SharedArrayBuffer вимагає заголовків
крос-доменної ізоляції (Cross-Origin-Opener-Policy: same-origin
і Cross-Origin-Embedder-Policy: require-corp), які ми
встановили глобально для цього сайту в Девлозі #14.