В 2017 году WebAssembly был объявлен для всех основных браузеров, и с тех пор он создает много шума из-за возможности запуска кода в сети почти с исходной скоростью, что дает разработчикам возможности для написания ресурсоемких приложений. Но действительно ли WebAssembly так хорош?
Обзор WebAssembly 👀
WebAssembly (Wasm) нацелен на обеспечение высокопроизводительных вычислений. Код Wasm скомпилирован в компактный двоичный формат, который работает почти с исходной производительностью.
Wasm — это строго типизированный код, который уже оптимизирован перед тем, как попасть в браузер, что значительно ускоряет его выполнение. В отличие от JavaScipt — интерпретируемого языка, может пройти некоторое время, прежде чем браузер полностью поймет, что он собирается выполнить.
Wasm использует статически типизированные языки, такие как C/C++ и Rust, для целевой компиляции.
Wasm не работает независимо, но предназначен для дополнения и работы вместе с JavaScript. Используя API-интерфейсы Wasm JavaScript, вы можете загружать модули Wasm в JavaScript и совместно использовать их функции. Это позволяет нам использовать преимущества производительности и мощности Wasm, а также выразительности и гибкости JavaScript в одних и тех же приложениях.
Позже в нашем эксперименте мы будем использовать Rust для создания образца Wasm.
Введение в проект 💿
Нам будет интересно узнать, какой подход Canvas более эффективен, и для этого мы исследуем следующие параметры: частота кадров, загрузка ЦП, использование памяти и время загрузки.
Для сравнения производительности мы будем использовать пример Canvas API с системой частиц.
Система частиц будет упрощена, но частица будет иметь свободное движение и будет проверена на столкновение с границами и другими частицами.
Чтобы посмотреть, какое максимальное количество частиц можно отобразить без лагов, добавим возможность гибко менять количество частиц.
Чтобы сэкономить время на тестирование и проведение измерений, мы предоставим API для автоматизации.
Для автоматизации измерений мы будем использовать Puppeteer— библиотеку, которая предоставляет высокоуровневый API для управления браузером по протоколу DevTools.
У нас будут реализации как JavaScript, так и Wasm для анимации частиц, и структура кода для обеих будет одинаковой, насколько это возможно.
В качестве языков программирования мы используем TypeScript для реализации JavaScript и Rust для WebAssembly.
Проект GitHub с кодовой базой находится здесь, а живое демо — здесь.
Реализация TypeScript
Давайте поместим весь необходимый код в один файл index.ts
.
/* Declare constants with values which used multiple times. */ const canvasSize = 500; const particleSize = 5; const lineWidth = 1; const maxSpeed = 3; const defaultParticleAmmount = 3000; /* Get number of particles from url params. */ const urlParams = new URLSearchParams(window.location.search); const rawParticles = urlParams.get('particles'); const particleAmmount = rawParticles ? Number(rawParticles) : 3000; /* Declare automation API on Window interface. */ interface Window { __FPS__?: number; } /* Particle parameters. */ interface Particle { x: number; y: number; speedX: number; speedY: number; } /* Initialize list of particles. */ const particles: Particle[] = []; /* Draw particle on cached canvas. */ function getParticleCanvas() { const particleCanvas = document.createElement("canvas"); particleCanvas.width = particleSize; particleCanvas.height = particleSize; const particleContext2d = particleCanvas.getContext("2d")!; particleContext2d.strokeStyle = "#aaa"; particleContext2d.lineWidth = lineWidth; particleContext2d.beginPath(); particleContext2d.arc( particleSize / 2, particleSize / 2, particleSize / 2 - lineWidth, 0, Math.PI * 2 ); particleContext2d.stroke(); return particleCanvas; } const particleCanvas = getParticleCanvas(); /* Setup main canvas element. */ const canvas = document.getElementById("canvas") as HTMLCanvasElement; canvas.width = canvasSize; canvas.height = canvasSize; const context2d = canvas.getContext("2d")!; /* Utils for particle initialization */ function getRandomPosition() { return Math.random() * (canvasSize - particleSize); } function getRandomSpeed() { const speed = 0.1 + Math.random() * (maxSpeed - 0.1); return Math.random() > 0.5 ? speed : -speed; } function initParticles() { for (let _ = 0; _ < particleAmmount; _++) { particles.push({ x: getRandomPosition(), y: getRandomPosition(), speedX: getRandomSpeed(), speedY: getRandomSpeed(), }); } } /* ---------- */ /* FPS helpers. */ let fps = 0; let fpsCounter = 0; let fpsTimestamp = 0; const fpsCount = 10; const second = 1000; function initFPSText() { context2d.fillStyle = "#0f0"; context2d.font = "14px Helvetica"; context2d.textAlign = "left"; context2d.textBaseline = "top"; context2d.fillText("fps: " + fps.toPrecision(4), 10, 10); } /* ---------- */ /* Main update callback */ function update(time: number) { /* Clear canvas at the beginning of each frame */ context2d.clearRect(0, 0, canvasSize, canvasSize); /* Loop through particle list */ for (let i = 0; i < particles.length; i++) { const particle = particles[i]; /* Check collision with vertical boundaries */ if ( (particle.x < 0 && particle.speedX < 0) || (particle.x > canvasSize - particleSize && particle.speedX > 0) ) { /* Change horizontal speed value to opposite */ particle.speedX = -particle.speedX; } /* Check collision with horizontal boundaries */ if ( (particle.y < 0 && particle.speedY < 0) || (particle.y > canvasSize - particleSize && particle.speedY > 0) ) { /* Change vertical speed value to opposite */ particle.speedY = -particle.speedY; } /* Loop again to check collision with other particles */ for (let j = 0; j < particles.length; j++) { /* Skip iteration for itself */ if (j === i) { continue; } const next = particles[j]; /* Calculate distance between particles */ const distance = Math.sqrt(Math.pow(next.x - particle.x, 2) + Math.pow(next.y - particle.y, 2)); /* Check particles collision */ if (distance < particleSize) { /* Change speed value to opposite */ particle.speedX = -particle.speedX; particle.speedY = -particle.speedY; } } /* Update position by actual speed value */ particle.x += particle.speedX; particle.y += particle.speedY; /* Draw particle with actual position*/ context2d.drawImage(particleCanvas, particle.x, particle.y); } /* Calculate number of passed frames */ fpsCounter++; /* Check if need to update FPS */ if (fpsCounter % fpsCount === 0) { /* Calculate time passed from last FPS update till now */ const delta = time - fpsTimestamp; /* Calculate new FPS value */ fps = (second * fpsCount) / delta; /* Update FPS value for test API */ window.__FPS__ = fps; /* Update last FPS update time */ fpsTimestamp = time; } /* Draw FPS text with actual value */ context2d.fillText("fps: " + fps.toPrecision(4), 10, 10); } /* Request animation loop */ function requestUpdate() { window.requestAnimationFrame((time: number) => { update(time); requestUpdate(); }); } initParticles(); initFPSText(); requestUpdate();
Компилятор TypeScript преобразует этот файл index.ts
в обычный файл JavaScript, который может быть обработан браузером. Также необходимо отметить, что здесь не используются никакие другие библиотеки для компиляции или минификации, кроме tsc
.
Теперь прикрепите транспилированный файл JavaScript к HTML-странице, и он готов к использованию.
<html> ... <body> <canvas id="canvas" /> <script src="index.js"></script> </body> </html>
Реализация ржавчины
С помощью диспетчера пакетов Rust инициализируйте проект cargo init --lib
и добавьте изменения в файл Cargo.toml
.
[package] name = "wasm-canvas" version = "0.1.1" edition = "2021" # A dynamic Rust library required for Wasm [lib] crate-type = ["cdylib"] [dependencies] # Provides random data rand = "0.8.5" # Facilitate high-level interactions between Wasm and JS wasm-bindgen = "0.2.83" # Provides bindings to JS global API js-sys = "0.3.60" # Retrieves random data from JS [dependencies.getrandom] version = "0.2.8" features = ["js"] # Provides bindings to Web API only for specified features [dependencies.web-sys] version = "0.3.60" features = [ 'Document', 'Element', 'HtmlElement', 'Node', 'Window', 'CanvasRenderingContext2d', 'HtmlCanvasElement', 'Location', 'UrlSearchParams' ]
А теперь добавьте анимацию системы частиц кода в файл src/lib.rs
.
/* Add extern crates */ use std::{cell::RefCell, f64::consts::PI, rc::Rc}; use js_sys::{self, Reflect}; use rand::random; use wasm_bindgen::{prelude::*, JsCast}; use web_sys::{ self, CanvasRenderingContext2d, HtmlCanvasElement, UrlSearchParams, }; /* Declare statics with values which used multiple times */ static CANVAS_SIZE: f64 = 500.0; static CIRCLE_SIZE: f64 = 5.0; static CIRCLE_RADIUS: f64 = CIRCLE_SIZE / 2.0; static LINE_WIDTH: f64 = 1.0; static MAX_SPEED: f64 = 3.0; static DEFAULT_CIRCLE_AMOUNT: u32 = 3000; static FPS_KEY: &str = "__FPS__"; /* Particle struct with parameters */ /* It derives Clone as particle list will be copied below */ #[derive(Clone)] struct Particle { x: f64, y: f64, speed_x: f64, speed_y: f64, } /* Utils for web-sys API */ fn window() -> web_sys::Window { web_sys::window().unwrap() } fn document() -> web_sys::Document { window().document().unwrap() } fn request_animation_frame(f: &Closure<dyn FnMut(f64)>) { window() .request_animation_frame(f.as_ref().unchecked_ref()) .unwrap(); } /* ---------- */ /* Get number of particles from uri params */ fn get_particle_amount() -> u32 { let uri_search_params = UrlSearchParams::new_with_str( window().location().search().unwrap().as_str() ).unwrap(); let particle_amout: u32 = uri_search_params .get("particles") .unwrap_or(DEFAULT_CIRCLE_AMOUNT.to_string()) .parse() .unwrap(); particle_amout } /* Draw particle on cached canvas */ fn get_particle_canvas() -> web_sys::HtmlCanvasElement { let canvas = document().create_element("canvas").unwrap(); let canvas: web_sys::HtmlCanvasElement = canvas .dyn_into::<web_sys::HtmlCanvasElement>() .map_err(|_| ()) .unwrap(); canvas.set_width(CIRCLE_SIZE as u32); canvas.set_height(CIRCLE_SIZE as u32); let context = canvas .get_context("2d") .unwrap() .unwrap() .dyn_into::<web_sys::CanvasRenderingContext2d>() .unwrap(); context.set_stroke_style(&"#aaa".into()); context.set_line_width(LINE_WIDTH.into()); context.begin_path(); context .arc( CIRCLE_RADIUS, CIRCLE_RADIUS, CIRCLE_RADIUS - LINE_WIDTH, 0.0, PI * 2.0, ) .unwrap(); context.stroke(); canvas } /* Utils for particle initialization */ fn get_random_position() -> f64 { (CANVAS_SIZE - CIRCLE_SIZE) * random::<f64>() } fn get_random_speed() -> f64 { let speed = 0.1 + (MAX_SPEED - 0.1) * random::<f64>(); if random::<bool>() { speed } else { -speed } } fn get_particles(particle_amout: u32) -> Vec<Particle> { let mut particles: Vec<Particle> = vec![]; for _ in 0..particle_amout { particles.push(Particle { x: get_random_position(), y: get_random_position(), speed_x: get_random_speed(), speed_y: get_random_speed(), }) } particles } /* ---------- */ /* FPS text helper */ fn init_fps_text(context_2d: &CanvasRenderingContext2d) { context_2d.set_fill_style(&"#0f0".into()); context_2d.set_font("14px Helvetica"); context_2d.set_text_align("left"); context_2d.set_text_baseline("top"); } /* Exposes function for JS API */ #[wasm_bindgen] pub fn render_particles() { let particle_amount = get_particle_amount(); let particle_canvas = get_particle_canvas(); /* Setup main canvas element */ let canvas = document().get_element_by_id("canvas").unwrap(); let canvas: HtmlCanvasElement = canvas .dyn_into::<HtmlCanvasElement>() .map_err(|_| ()) .unwrap(); canvas.set_width(CANVAS_SIZE as u32); canvas.set_height(CANVAS_SIZE as u32); let context_2d = canvas .get_context("2d") .unwrap() .unwrap() .dyn_into::<CanvasRenderingContext2d>() .unwrap(); init_fps_text(&context_2d); /* Initialize list of particles */ let mut particles = get_particles(particle_amount); /* FPS helpers. */ let mut fps = 0_f64; let mut fps_counter = 0_u32; let mut fps_timestamp = 0_f64; let fps_count = 10_u32; let second = 1000_f64; /* Persistent reference to closure for future iterations */ let update: Rc<RefCell<Option<Closure<dyn FnMut(f64)>>>> = Rc::new(RefCell::new(None)); /* Reference to closure requests for first frame and then it's dropped */ let request_update = update.clone(); /* Main update closure */ *request_update.borrow_mut() = Some(Closure::new(move |time| { /* Clear canvas at the beginning of each frame */ context_2d.clear_rect(0.0, 0.0, CANVAS_SIZE, CANVAS_SIZE); /* Clone particles for inner iteration */ let next_particles = particles.to_vec(); /* Loop through particle list */ for i in 0..particle_amount as usize { let mut particle = particles.get_mut(i).unwrap(); /* Check collision with vertical boundaries */ if (particle.x < 0.0 && particle.speed_x < 0.0) || (particle.x > CANVAS_SIZE - CIRCLE_SIZE && particle.speed_x > 0.0) { /* Change horizontal speed value to opposite */ particle.speed_x = -particle.speed_x; } /* Check collision with horizontal boundaries */ if (particle.y < 0.0 && particle.speed_y < 0.0) || (particle.y > CANVAS_SIZE - CIRCLE_SIZE && particle.speed_y > 0.0) { /* Change vertical speed value to opposite */ particle.speed_y = -particle.speed_y; } /* Loop again to check collision with other particles */ for j in 0..particle_amount as usize { /* Skip iteration for itself */ if j == i { continue; } let next = next_particles.get(j).unwrap(); /* Calculate distance between particles */ let distance = ( (next.x - particle.x).powf(2.0) + (next.y - particle.y).powf(2.0) ).sqrt(); /* Check particles collision */ if distance < CIRCLE_SIZE { /* Change speed value to opposite */ particle.speed_x = -particle.speed_x; particle.speed_y = -particle.speed_y; } } /* Update position by actual speed value */ particle.x += particle.speed_x; particle.y += particle.speed_y; /* Draw particle with actual position*/ context_2d .draw_image_with_html_canvas_element( &particle_canvas, particle.x, particle.y, ) .unwrap(); } /* Calculate number of passed frames */ fps_counter += 1; /* Check if need to update FPS */ if fps_counter % fps_count == 0 { /* Calculate time passed from last FPS update till now */ let delta: f64 = time - fps_timestamp; /* Calculate new FPS value */ fps = (second * fps_count as f64) / delta; /* Update FPS value for test API on Window object */ Reflect::set( &JsValue::from(window()), &JsValue::from(FPS_KEY), &fps.into(), ) .unwrap(); /* Update last FPS update time */ fps_timestamp = time; } /* Draw FPS text with actual value */ context_2d .fill_text(format!("fps: {:.2}", fps).as_str(), 10.0, 10.0) .unwrap(); /* Request next frame */ request_animation_frame(update.borrow().as_ref().unwrap()); })); /* Request first frame */ request_animation_frame(request_update.borrow().as_ref().unwrap()); }
Теперь выполните следующие команды, чтобы сделать сборку доступной для браузера:
cargo build --release --target wasm32-unknown-unknown
wasm-bindgen --target web ./target/wasm32-unknown-unknown/release/wasm_canvas.wasm --out-dir ./dist
Наконец, аналогично TypeScript, добавьте сгенерированный файл JavaScript wasm_canvas.js в HTML, но немного иначе, поскольку Wasm требует инициализации.
<html> ... <body> <canvas id="canvas" /> <script type="module"> import init, { render_particles } from "./wasm_canvas.js"; init().then(() => render_particles()); </script> </body> </html>
Тестовая среда 💻
Для проведения замеров взят MacBook Pro 16 дюймов 2019 года со следующими характеристиками:
- Процессор: 8-ядерный Intel Core i9 с тактовой частотой 2,3 ГГц
- Графика: AMD Radeon Pro 5500M 4 ГБ
- Память: 16 ГБ 2667 МГц DDR4
- macOS Вентура версии 13.1
Браузер для запуска веб-проекта представляет собой сборку разработчика Chromium версии 109.0.5412.0, контролируемую Puppeteer. На момент тестирования все расширения браузера отключены.
Теперь перейдем к результатам.
Загрузка процессора 📈
Во-первых, посмотрим на потребление процессора и как оно зависит от количества частиц.
Для JavaScript заметна линейная зависимость загрузки процессора от количества частиц. Для 1700 частиц процессор загружен полностью на 100%, а значит ресурсов для выполнения других задач больше нет.
Реализация WebAssably выглядит немного лучше, кривая более плоская. И 100% загрузка процессора начинается с 2200 частиц.
Частота кадров в секунду (FPS)🎞️
Понятно, что фпс зависит от загрузки процессора и это обратно пропорциональная зависимость.
Как и ожидалось, JavaScript FPS падает на 1700 частиц, а WebAssembly — на 2200 частиц.
Использование памяти 💾
Теперь проверьте динамическую память JavaScript и WebAssembly на наличие 3000 частиц.
Браузер снова открывался, и перед каждым моментальным снимком кучи вызывался сборщик мусора.
Статистика кучи JavaScript и WebAssembly здесь.
Мы видим, что потребление памяти для JavaScript (всего 1241 КБ) меньше, чем для WebAssembly (всего 2474 КБ), особенно заметно для типизированных массивов (36 КБ против 1468 КБ).
Сеть и время загрузки ⏱️
И, наконец, проинспектируем сеть в DevTools и посмотрим на HTTP-запросы и время их обработки.
С дросселированием сети мы будем имитировать медленное интернет-соединение 3G.
Для JavaScript можно отметить только 3 ресурса: HTML document
, index.css
и index.js
. Общие размеры файлов крошечные, даже index.js
занимают всего 1,3 КБ. А общее время загрузки составляет 4,10 секунды.
У WebAssembly есть еще один запрос во вкладке Сеть, это wasm
файл и он занимает уже 43,6кБ. А также wasm_canvas.js
имеет 4,8 КБ, что является JavaScript API для модуля Webassembly. В идеале thewasm
module мог бы быть меньше, но причины фактического размера — дополнительные библиотеки для случайных данных, DOM и Canvas API. А общее время загрузки всех ресурсов составляет 7,08 секунды.
Вывод 📍
Подход WebAssembly имеет преимущество перед JavaScript в отношении использования ЦП и стабильного FPS.
С точки зрения размера файла решение JavaScript более компактно, и, как следствие, время загрузки быстрее, чем у WebAssembly для текущего проекта.
От проекта к проекту необходимо учитывать все факторы. В некоторых случаях время загрузки важнее скорости вычислений, а в других — производительность важнее времени загрузки.
Есть множество других факторов, которые следует учитывать. WebAssembly требует знания языка низкого уровня, такого как C/C++ или Rust, что не позволяет WebAssembly быть столь же популярным, как JavaScript.
Подводя итог, используйте JavaScript, пока он работает, и используйте WebAssembly, когда требуется повышение производительности.