В 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. В идеале thewasmmodule мог бы быть меньше, но причины фактического размера — дополнительные библиотеки для случайных данных, DOM и Canvas API. А общее время загрузки всех ресурсов составляет 7,08 секунды.

Вывод 📍

Подход WebAssembly имеет преимущество перед JavaScript в отношении использования ЦП и стабильного FPS.

С точки зрения размера файла решение JavaScript более компактно, и, как следствие, время загрузки быстрее, чем у WebAssembly для текущего проекта.

От проекта к проекту необходимо учитывать все факторы. В некоторых случаях время загрузки важнее скорости вычислений, а в других — производительность важнее времени загрузки.

Есть множество других факторов, которые следует учитывать. WebAssembly требует знания языка низкого уровня, такого как C/C++ или Rust, что не позволяет WebAssembly быть столь же популярным, как JavaScript.

Подводя итог, используйте JavaScript, пока он работает, и используйте WebAssembly, когда требуется повышение производительности.

Ресурсы