Сравнение TypeScript (Bun), Dart→JS (dart2js) и Dart→WASM (dart2wasm)
как host-языков, гоняющих настоящий three.js WebGPURenderer. Все реализации
крутят один и тот же движок; three.js — идентичное tree-shaken подмножество, так
что разница в размере = код приложения + рантайм языка, а разница в CPU-времени =
host-язык (GPU-путь общий).
Dart-сторона использует extension type + dart:js_interop поверх
globalThis.THREE (ручные биндинги). TS импортирует three/webgpu напрямую.
- Удалённый Chrome 150 / Windows, бэкенд real WebGPU, GPU NVIDIA Ada Lovelace (RTX 40). Дисплей 120 Гц (rAF-кап ~122 fps).
- 4 режима-эксперимента × 3 реализации × свип по N. Метрики пишутся из host-кода
и POST-ятся на сервер (
results/report.jsonl). Детерминированный ворклоад (mulberry32 bit-exact, fixed dt), идентичный во всех реализациях (SPEC.md). - Шум среды: виртуалка даёт джиттер планировщика,
avg/P95 загрязнены спайками. Опора наmin(самый невытесненный кадр = чистая скорость кода) иP50. Интересны отношения между реализациями, не абсолютные числа.
| Ось | Победитель | Комментарий |
|---|---|---|
| Размер загрузки | TS/Bun | +2–5 КБ склейки vs +31–41 КБ рантайма Dart (фикс. налог, амортизируется) |
| Тесный числовой цикл (boids) | dart2wasm | ~10 % быстрее на масштабе |
| Тяжёлая ветвистая логика (fat) | ничья | преимущество wasm исчезает |
| Болтливый интероп (per-object) | dart2js ≈ TS | dart2wasm ~1.5–2× медленнее... |
| …но при батчинге (single buffer) | ничья | wasm-налог 1.8→0.3 мс, даже быстрее (Эксп.3b) |
| GPU/render-bound (gpu) | ничья | язык нерелевантен |
Главный вывод: производительность зависит от ворклоада и от паттерна интеропа сильнее, чем от языка. Один и тот же dart2wasm бывает на ~10 % быстрее (bulk- upload) или на ~70 % медленнее (per-object writes) — решает архитектура границы JS.
Флокинг через spatial grid, одна InstancedMesh, bulk-upload матриц. simMs (min | P50), меньше лучше:
| N | ts | dart2js | dart2wasm | fps ts/d2js/wasm |
|---|---|---|---|---|
| 4 000 | 2.8 | 3.7 | 2.3 | 3.7 | 2.4 | 3.3 | 117 / 116 / 118 |
| 8 000 | 4.1 | 5.8 | 4.1 | 5.8 | 3.7 | 5.2 | 107 / 106 / 109 |
| 16 000 | 7.0 | 10.9 | 7.3 | 10.5 | 6.5 | 9.5 | 78 / 80 / 86 |
| 32 000 | 16.0 | 21.4 | 15.9 | 21.7 | 14.3 | 19.6 | 43 / 43 / 45 |
dart2wasm стабильно быстрее на ~10 % (WasmGC+AOT на числовом коде без JIT- прогрева). dart2js ≈ TS — ничья.
Тот же рендер, но per-agent: видовой флокинг (3 вида, весовая матрица) + wander
(hash-noise) + 20 препятствий + 10 хищников с бегством + 12 источников еды +
FSM + энергетика. simMs (min | P50):
| N | ts | dart2js | dart2wasm |
|---|---|---|---|
| 8 000 | 4.9 | 6.8 | 4.9 | 6.9 | 4.8 | 6.9 |
| 16 000 | 9.7 | 13.7 | 9.7 | 13.9 | 9.6 | 13.4 |
| 32 000 | 20.6 | 26.0 | 21.0 | 25.8 | 20.6 | 25.2 |
Все три равны. Ключевой нюанс: как только в горячем цикле появляется много ветвлений и трансцендентной математики (sin/cos/sqrt), преимущество wasm исчезает — тригонометрия и предсказание ветвлений у всех трёх упираются в одно и то же. Преимущество wasm специфично для плотных array-числодробилок (как boids). Fat при этом заметно тяжелее boids (~25–30 % на N=32k) — это честно «раздутая» логика, а не пустышка.
N отдельных Mesh, каждый кадр mesh.position.set() + mesh.rotation.set() —
тысячи пересечений границы host↔JS. syncMs (P50) — стоимость именно интероп-записей:
| N | ts | dart2js | dart2wasm |
|---|---|---|---|
| 2 000 | 0.2 | 0.2 | 0.3 |
| 4 000 | 0.5 | 0.4 | 0.8 |
| 8 000 | 1.2 | 1.0 | 1.7 |
dart2wasm платит больше всех за «болтливый» интероп (~1.5–2× от TS): каждый
вызов position.set маршалит через границу WASM↔JS. dart2js — самый быстрый
(компилит доступ к свойствам в прямой JS, V8 инлайнит). Это зеркало Эксп.1:
там wasm выигрывал на bulk-upload, здесь проигрывает на per-object — паттерн
интеропа важнее языка. (FPS при больших N тут упирается в число draw-call'ов
three.js и одинаков у всех.)
Та же motion, что в interop (N орбитирующих объектов), но доставка на GPU через
один матричный буфер InstancedMesh (одно пересечение границы за кадр) вместо
N пар position.set/rotation.set. Сравнение в одной сессии:
| N=8000 | interop (per-object) | batch (single buffer) |
|---|---|---|
| syncP50, мс — ts / d2js / wasm | 1.4 / 1.0 / 1.8 | 0.0 / 0.0 / 0.3 |
| cpuFrameP50, мс — ts / d2js / wasm | 28.5 / 28.1 / 30.5 | 1.7 / 1.7 / 1.6 |
| fps — ts / d2js / wasm | 34.9 / 35.5 / 32.6 | 120.7 / 120.7 / 118.9 |
Налог wasm на интероп схлопывается: 1.8 → 0.3 мс (одно пересечение вместо тысяч),
и в batch wasm даже самый быстрый по кадру. Батчинг — огромный выигрыш для всех
трёх (draw-call'ов 1 вместо N: cpuFrame ~28→1.7 мс, fps 35→120), но wasm он помогает
непропорционально сильнее, т.к. убирает пер-объектный маршалинг. Вывод: правильно
спроектированная граница (bulk-передача типизированного буфера) делает dart2wasm
полностью конкурентным — «интероп-обрыв» был артефактом болтливого паттерна, а не
самого языка. На практике: тысячи объектов → InstancedMesh + матричный буфер,
а не тысячи Object3D с сеттерами.
Высокополигональные статичные инстансы (Icosahedron detail=6, ~82k tris каждый), 4× суперсэмплинг, ~0 CPU/кадр. FPS | cpuFrame P50:
| N | ts | dart2js | dart2wasm |
|---|---|---|---|
| 8 000 | 120.6 | 0.2 | 120.9 | 0.2 | 120.6 | 0.2 |
| 16 000 | 66.7 | 0.2 | 65.0 | 0.2 | 63.5 | 0.2 |
| 32 000 | 23.9 | 0.2 | 23.3 | 0.1 | 23.0 | 0.1 |
Идентично у всех трёх. При N=16k–32k сцена реально GPU-bound (fps падает при CPU ~0.2 мс) — и язык на это не влияет вообще. Побочное наблюдение: этот RTX настолько быстр, что до 8k инстансов даже 82M tris + 4× SS упираются в vsync, а не в GPU — типичная three.js-сцена на топовом dGPU почти всегда CPU/vsync-bound.
Brotli (q11), байты. «app-only» = вклад языка (three исключён):
| app-only (lean, только boids) | app-only (fat, 4 режима) | Δ за 3 режима | |
|---|---|---|---|
| ts | 2 323 | 4 744 | +2 421 |
| dart2js | 31 424 | 34 364 | +2 940 |
| dart2wasm | 37 307 | 40 803 | +3 496 |
Полная загрузка (fat-сборка), brotli: three (общий, 173.8 КБ) +
| app | итого | |
|---|---|---|
| ts (three в бандле) | — | 178 КБ |
| dart2js | 34.4 КБ | 208 КБ |
| dart2wasm | 40.8 КБ | 215 КБ |
Про амортизацию. Рантайм Dart — фиксированный налог: разрыв (Dart − TS) по app-only держится ~29 КБ (dart2js) / ~35 КБ (wasm) независимо от размера кода. Поэтому относительный оверхед тает по мере роста приложения:
- lean: dart2js/TS = 31.4 / 2.3 = 13.6×
- fat: dart2js/TS = 34.4 / 4.7 = 7.3×
- гипотетически ~100 КБ реального app-кода: ~131 / ~102 = ~1.3×
Добавление одинаковых фич стоит по коду сопоставимо во всех трёх (TS +2.4, dart2js +2.9, dart2wasm +3.5 КБ), так что для тонкой обёртки над three.js рантайм Dart — доминирующая цена, а для крупной игры он размывается почти в ноль. three.js (~174 КБ) в любом случае доминирует в бандле (~80–98 % итога).
- TypeScript + Bun — если это тонкая игра-обёртка над three.js, важен вес загрузки и DX: минимальный бандл, типы three бесплатно, ноль ручных биндингов, предсказуемая V8-скорость везде.
- dart2wasm — если ядро игры это тяжёлая числовая симуляция в тесных циклах
(частицы, физика, N-body) и/или нужен шаринг кода с Dart/Flutter. Даёт ~10 % на
таком compute. Плати ~40 КБ рантайма и проектируй границу под bulk-передачу
(матрицы в
InstancedMesh-буфер, один crossing/кадр). При правильном батчинге wasm полностью конкурентен и даже самый быстрый (Эксп.3b: налог 1.8→0.3 мс); «интероп-обрыв» возникает только на болтливом per-object паттерне (тысячиmesh.position.setв кадр) — его и надо избегать, а не язык. - dart2js — самый безопасный Dart-выбор: скорость на уровне TS везде, лучший на болтливом интеропе, рантайм меньше wasm, максимальная совместимость. Берут ради переиспользования Dart-кода без «интероп-обрывов» wasm.
Если одним предложением: язык почти не решает — решают ворклоад и паттерн интеропа. three.js везде одинаков; на GPU-bound и на тяжёлой ветвистой логике все три равны; wasm выигрывает узко (плотный числовой код + bulk-upload) и проигрывает узко (болтливый per-object интероп); dart2js держит паритет с TS во всём, а TS выигрывает по весу за счёт отсутствия рантайма языка.