Порада: WebAssembly для браузерних фізичних рушіїв — коли і як використовувати WASM

WASM — не магія. Але для правильних задач — щільних числових циклів, детермінованих розкладок пам'яті або портування наявних розв'язувачів на C/C++/Rust — він може дати вам приріст пропускної здатності у 3–6× порівняно з ідіоматичним JavaScript. Ось практичний план дій.

Проблема великих фізичних циклів на 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. Мінімальне налаштування виглядає так:

Cargo.toml (крейт Rust WASM)
[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
src/lib.rs — Розв'язувач обмежень тканини (експорт у WASM)
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;
    }
}

Компіляція та збірка:

Shell
wasm-pack build --target web --release

Спільні буфери без копіювання з JS

Найважливіша оптимізація при виклику WASM з JS — ніколи не копіювати дані. Виділіть один Float32Array, що спирається на лінійну пам'ять WASM, і дозвольте і JS (для рендерингу), і WASM (для фізики) працювати з тими самими байтами.

JavaScript — спільний буфер без копіювання
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?

✓ Використовуйте WASM, коли…
  • Фізичний цикл займає >3 мс/кадр у JS
  • У вас >5000 частинок або обмежень
  • Потрібна детермінована крос-платформна арифметика
  • Ви портуєте наявний розв'язувач на C/C++/Rust
  • Застосовний SIMD (релаксація f32×4 SIMD)
✗ Пропустіть WASM, коли…
  • Крок фізики вже <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 в основному потоці. Це тримає обидва потоки повністю завантаженими:

Зауваження: SharedArrayBuffer вимагає заголовків крос-доменної ізоляції (Cross-Origin-Opener-Policy: same-origin і Cross-Origin-Embedder-Policy: require-corp), які ми встановили глобально для цього сайту в Девлозі #14.