WASM + WebGPU: ризики та переваги

🎨 Комп'ютерна графіка ⏱ ~11 хв читання Складний рівень
WebAssembly WebGPU Продуктивність Архітектура Rust

WebAssembly дає браузеру майже нативне виконання CPU-коду; WebGPU дає сучасний low-overhead GPU API з повноцінними compute-шейдерами. Разом вони дозволяють запускати числове ядро фізичної симуляції у WASM, а рендеринг чи обчислення частинок — на GPU, без жодного нативного інсталятора. Проблема в тому, що "майже нативне" і "сучасне" мають гострі кути: копіювання пам'яті через межу JS, нерівномірне впровадження WebGPU по браузерах і білд-ланцюжок, що досі дозріває. Ця стаття розбирає архітектуру, реальні цифри продуктивності і коли додаткова складність себе виправдовує.

Коротко: WebAssembly виконує CPU-логіку симуляції (детекцію зіткнень, розв'язання constraint'ів) майже з нативною швидкістю, а WebGPU compute-шейдери беруть на себе масово-паралельну GPU-математику — але кожен перехід між WASM, JS і GPU коштує копіювання пам'яті. Гібридна архітектура виправдовує себе лише тоді, коли профайлер підтверджує реальний CPU-вузол, є великий паралельний GPU-обсяг роботи, і проєкт може дозволити собі підтримувати WebGL2 fallback.

1. Навіщо поєднувати WASM і WebGPU

WebAssembly і WebGPU вирішують два різні вузькі місця. WASM замінює повільний, непередбачувано JIT-компільований JavaScript для числових обчислень (детекція зіткнень, розв'язувачі жорстких тіл, просторове хешування, розв'язання constraint'ів тканини) на компактний байткод, що працює у 1.3–3× швидше за ретельно оптимізований JS і з набагато стабільнішим часом кадру — без GC-пауз у гарячих циклах, написаних на Rust чи C++ з ручним керуванням пам'яттю. WebGPU замінює трюки WebGL з compute-обчисленнями лише через фрагментні шейдери (кодування даних у текстури, щоб імітувати загальні GPU-обчислення) на справжні compute-шейдери, storage-буфери та явну синхронізацію.

Жодна з цих технологій сама по собі не вирішує всю проблему для серйозної симуляції. Розв'язувач тканини потребує CPU-прохід broad-phase колізій (нерегулярний потік керування, важко розпаралелити на GPU) і GPU-прохід масового оновлення частинок (легко паралелиться, тисячі вершин за кадр). Розділення роботи саме по цій лінії розлому — CPU-дружня логіка у WASM, GPU-дружня математика у WGSL compute — і є патерн, який ця стаття називає "гібридним рушієм".

Сильні сторони WASM

Передбачувана продуктивність, без GC-джитеру, повторне використання наявного C++/Rust фізичного коду, SIMD128 для векторизованої математики.

Сильні сторони WebGPU

Справжні compute-шейдери, storage-буфери, явні пайплайни, краще багатопотокове записування команд ніж у WebGL.

Спільна слабкість

Кожен перехід через межу WASM↔JS↔GPU коштує копіювання чи точки синхронізації — архітектура має мінімізувати ці переходи.

2. Референсна архітектура: CPU-ядро + GPU-обчислення

Типовий гібридний цикл симуляції тримає живими три буфери: WASM linear memory (позиції/швидкості частинок як плаский Float32Array), GPU storage-буфер, що дзеркалить той самий layout, і невеликий JS клейовий шар, що копіює між ними лише раз за кадр — не на кожну частинку.

class HybridSimulation {
  async init() {
    // 1. Ініціалізуємо WASM-модуль (скомпільований Rust/C++ solver)
    const { instance } = await WebAssembly.instantiateStreaming(
      fetch('solver.wasm'), { env: this.importObject() }
    );
    this.wasm = instance.exports;
    this.particlesPtr = this.wasm.alloc_particles(100000);

    // 2. Отримуємо WebGPU-девайс + storage-буфер того ж розміру
    const adapter = await navigator.gpu.requestAdapter();
    this.device = await adapter.requestDevice();
    this.gpuBuffer = this.device.createBuffer({
      size: 100000 * 4 * 4, // vec4 на частинку
      usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
    });
  }

  step(dt) {
    // CPU: розв'язання constraint'ів, broad-phase колізії, контакти (нерегулярне)
    this.wasm.solve_constraints(this.particlesPtr, dt);

    // Одне копіювання за кадр: WASM linear memory → GPU staging
    const view = new Float32Array(
      this.wasm.memory.buffer, this.particlesPtr, 100000 * 4
    );
    this.device.queue.writeBuffer(this.gpuBuffer, 0, view);

    // GPU: масово-паралельне інтегрування + рендеринг (регулярне, легко паралелиться)
    this.dispatchComputePass();
  }
}

Ключова дисципліна: WASM володіє нерегулярною, розгалуженою логікою; WGSL compute володіє регулярною, паралельною математикою. JS клейовий шар робить рівно одне завантаження буфера за кадр, а не на кожен об'єкт.

3. Межа пам'яті: linear memory vs GPU-буфери

Linear memory WASM — це один суцільний ArrayBuffer, читається з JS напряму без жодного маршалінгу — саме тому межа WASM↔JS дешева порівняно, скажімо, з викликом нативного addon'у. Межа JS↔GPU — зовсім інша історія: queue.writeBuffer запускає копіювання на рівні драйвера з CPU RAM у VRAM (або швидкий шлях через спільну пам'ять на інтегрованих GPU), а читання даних назад (mapAsync) асинхронне і може заблокувати кадр, якщо користуватись ним необережно.

Модель вартості за кадр (порядок величини, дискретна GPU, PCIe 4.0):

Створення view WASM↔JS: ~0 (та сама пам'ять, без копіювання)

JS→GPU writeBuffer (1 МБ): ~0.05–0.2 мс

GPU→JS mapAsync readback (1 МБ): ~1–3 мс + ризик зупинки пайплайна

Правило: завантажуй кожен кадр, читай назад тільки коли неминуче (напр. GPU picking, debug)

Уникнення round-trip'ів

Найбільший вбивця продуктивності у гібридних рушіях — round-trip CPU→GPU→CPU всередині кадру (наприклад, запуск фізики на GPU, а потім читання результатів назад у WASM для реакції на зіткнення). Де можливо, тримайте весь ланцюжок залежностей кадру з одного боку: або GPU compute напряму годує GPU-рендеринг (без readback), або WASM обчислює все і лише фінальний vertex-буфер іде на GPU один раз.

4. Підтримка WebGPU у браузерах та fallback

WebGPU за замовчуванням запрацював у Chrome/Edge (2023), а Firefox і Safari додали власну підтримку за своїми графіками протягом 2024–2025 років; покриття тепер широке на десктопі, але все ще нерівномірне на деяких мобільних браузерах і старіших версіях Safari. Будь-яка продакшн-симуляція потребує fallback-шляху на WebGL2 — це означає або підтримку двох рендер-бекендів, або використання бібліотеки-абстракції, що компілює WGSL-подібний код в обидві цілі.

МожливістьWebGL2WebGPU
Compute-шейдериНі (лише хаки з текстурами)Так, повноцінно
Storage-буфериНіТак
Багатопотокове записування командНіТак
Bindless-подібне прив'язування ресурсівЛише через розширенняBind groups (нативно)
Мова шейдерівGLSL ESWGSL
Практичний патерн: перевіряємо наявність navigator.gpu, і якщо його немає — переключаємось на WebGL2-рендерер, що читає той самий буфер, згенерований WASM, через bufferSubData. Ядро симуляції у WASM не повинно знати, який бекенд його рендерить.

5. Бенчмарки: WASM проти JS проти нативного коду

Цифри сильно варіюються залежно від навантаження, але у опублікованих бенчмарках для числового, без-розгалужень коду (SIMD-дружнє інтегрування частинок, матрична математика) з'являється стійкий патерн: WASM опиняється приблизно на 50–80% швидкості нативного C++ і у 1.5–4× швидше за еквівалентний гарячий цикл JavaScript — здебільшого через уникнення деоптимізації типів і передбачувану розкладку пам'яті. Для коду з великою кількістю розгалужень (обхід дерев, побудова BVH) розрив з нативним кодом зростає, оскільки overhead неінлайнованих викликів функцій та перевірок меж пам'яті у WASM важить більше.

НавантаженняJS (V8, прогріте)WASMНативний код
Verlet-інтегрування 10к частинок~2.5×~3.5×
Побудова BVH (100к трикутників)~1.8×~3×
SIMD dot-product обчислення~4×~5×

Висновок: WASM — це не "безкоштовна нативна швидкість", а велике, надійне покращення над JS для числового коду, причому SIMD128 звужує розрив з нативним кодом ще більше. Чи варта ця складність білду — залежить від того, чи числове ядро справді є вузьким місцем; спочатку профілюйте.

6. Типові пастки

Зростання WASM-пам'яті

memory.grow() може зробити недійсними всі наявні Float32Array-view у ній. Пересоздавайте view після кожного зростання, або одразу виділяйте великий фіксований розмір.

Асинхронність WebGPU всюди

Створення девайсу, мапінг буфера і створення пайплайну — усе на Promise. Очікування їх усередині рендер-циклу блокує кадри — компілюйте пайплайни лише при ініціалізації.

Непрозорість дебагу

Крах усередині скомпільованого Rust/C++ проявляється як незрозуміла WASM-пастка (trap). Тримайте source maps і debug-варіант білду; не дебажте наосліп у release WASM.

Розмір бандла

Повноцінний фізичний рушій, скомпільований у WASM, може важити 500КБ–2МБ. Використовуйте wasm-opt, зрізайте debug-інформацію і завантажуйте модуль ліниво за взаємодією "почати симуляцію".

7. Коли ця комбінація виправдана

Не кожній симуляції потрібні обидві технології одразу. Невелика демонстрація з кількома тисячами частинок чудово працює на чистому JS + WebGL. Гібридна архітектура WASM+WebGPU виправдовує свою складність, коли справджуються принаймні два з цих пунктів: CPU-логіка є справжнім вузьким місцем (виміряним профайлером, а не припущеним), навантаження має велику легко-паралельну GPU-складову, що виграє від справжніх compute-шейдерів, і проєкт може дозволити собі підтримувати WebGL2 fallback для ~10–20% відвідувачів на браузерах без WebGPU.

Для власних важчих демонстрацій MySimulator — великих N-body систем, тканини з десятками тисяч constraint'ів, SPH-рідин — це саме та форма проблеми: WASM обробляє розв'язувач і просторові структури, WebGPU compute обробляє масово-паралельне інтегрування та прохід рендерингу.

🌊 Відкрити симуляцію рідини →

🔗 Пов'язані симуляції

🌊Рідина 🪐N-тіл 🧵Тканина