JavaScript vs WebAssembly performance for Canvas particle system

Leo
Source True
Published in
11 min readJan 12, 2023

In 2017 WebAssembly was declared for all major browsers and since that time it generate a lot of noise, owing to be able to run code on a web at near-native speed which give developers possibilities for writing computing-intensive applications. But is WebAssembly really so great?

Particle system animation example.
Particle system animation

WebAssembly Overview 👀

WebAssembly (Wasm) aims to provide high-performance computations. Wasm code is compiled to a compact binary format that runs with near-native performance.

Wasm is strongly typed code that is already optimized before getting to the browser, making execution quite faster. Unlike, JavaScipt t is an interpreted language, it might take a while before the browser fully understands what it’s about to execute.

Wasm uses statically typed languages such as C/C++ and Rust for target compilation.

Wasm doesn’t work independently but it’s designed to complement and run alongside JavaScript. Using the Wasm JavaScript APIs, you can load Wasm modules into JavaScript and share functionality between the two. This allows us to take advantage of Wasm's performance and power and JavaScript’s expressiveness and flexibility in the same applications.

Later, in our experiment, we will use Rust for creating the Wasm sample.

Project Introduction 💿

It will be interesting for us to find out which Canvas approach is more performant and for this, we will investigate the following parameters: frame rate, CPU load, memory usage, and loading time.

For performance comparison, we will use the Canvas API example with a particle system.

The particle system will be simplified but the particle will have free movement and will be checked for collision with boundaries and other particles.

To see what maximum particles can be displayed without lags, we will add the ability to change the number of particles flexibly.

To save time for testing and taking measurements, we will expose the API for automation.

For measurement automation, we will use Puppeteerlibrary which provides a high-level API to control the browser over the DevTools Protocol.

We will have both JavaScript and Wasm implementations for our particle animation, and the code structure for both will be the same as far as possible.

As coding languages, we use TypeScript for JavaScript implementation and Rust for WebAssembly.

The GitHub project with the codebase is here and check the live demo here.

TypeScript Implementation

Let’s place all the needed code into a single index.ts file.


/* 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 compiler transpiles this index.ts file to a plain JavaScript file which can be processed by the browser. Also need to mention that no other libraries are used here for compilation or minification except tsc.

Now attach the transpiled JavaScript file to the HTML page and it's ready for usage.

<html>
...
<body>
<canvas id="canvas" />
<script src="index.js"></script>
</body>
</html>

Rust Implementation

With the Rust package manager, initialize the project cargo init --lib, and add changes to Cargo.toml file.

[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'
]

And now add code particle system animation to src/lib.rs file.

/* 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());
}

Now run the following commands to make the build available to the browser:

  • cargo build --release --target wasm32-unknown-unknown
  • wasm-bindgen --target web ./target/wasm32-unknown-unknown/release/wasm_canvas.wasm --out-dir ./dist

Finally, similarly to TypeScript, add the generated wasm_canvas.js JavaScript file to the HTML but a little bit differently as Wasm requires initialization.

<html>
...
<body>
<canvas id="canvas" />
<script type="module">
import init, { render_particles } from "./wasm_canvas.js";
init().then(() => render_particles());
</script>
</body>
</html>

Test Environment 💻

To take measurements are taken on a 16-inch MacBook Pro 2019 with the following characteristics:

  • Processor: 2.3 GHz 8-Core Intel Core i9
  • Graphics: AMD Radeon Pro 5500M 4 GB
  • Memory: 16 GB 2667 MHz DDR4
  • macOS Ventura Version 13.1

A browser for running a web project is a developer build of Chromium with version 109.0.5412.0 controlled by Puppeteer. At the moment of testing all browser extensions are disabled.

Now let’s go to the results.

CPU usage 📈

Firstly will look at CPU consumption and how it depends on the number of particles.

CPU usage curves for JS and Wasm.
CPU load dependency on a number of particles.

For JavaScript, noticeable a linear dependence of processor load on the number of particles. For 1700 particles, the processor is fully loaded at 100%, which means that there are no more resources to perform other tasks.

WebAssably implementation looks a bit better, the curve is flatter. And CPU 100% load starts at 2200 particles.

Frame rate per second (FPS)🎞️

It is clear that the FPS depends on the CPU load and this is inversely proportional dependency.

FPS curves for JS and Wasm.
FPS dependency on a number of particles.

As expected, JavaScript FPS goes down for 1700 particles, and WebAssembly — for 2200 particles.

Memory usage 💾

Now check the heap memory of both JavaScript and WebAssembly for 3000 particles.

The browser was reopened and the garbage collector was called before each heap snapshot.

JavaScript and WebAssembly heap statistics are here.

Total memory for JS — 1241kB.
JavaScript memory statistics.
Total memory for Wasm — 2474kB.
WebAssembly memory statistics.

We see that the memory consumption for JavaScript (total 1241kB) is less than for WebAssembly (total 2474kB), especially noticeable for typed arrays (36 kB versus 1468 kB).

Network and loading time ⏱️

And finally, let’s inspect the network in DevTools and look at the HTTP requests and their processing time.

With network throttling, we will simulate a slow 3G internet connection.

JavaScript network statistics on slow 3G.
JavaScript network statistics on slow 3G.

For JavaScript, can be noticed only 3 resources: HTML document, index.css, and index.js. Overall file sizes are tiny, even index.js taking only 1.3kB. And total loading time is 4.10 seconds.

WebAssembly network statistics on slow 3G.
WebAssembly network statistics on slow 3G.

WebAssembly has one more request in the Network tab, it is wasm a file and it takes already 43.6kB. And also wasm_canvas.js has 4.8kB, which is JavaScript API for Webassembly module. Ideally, thewasmmodule could be smaller, but the reasons for the actual size are additional libraries for random data, DOM, and Canvas API. And overall loading time for all resources is 7.08 seconds.

Conclusion 📍

The WebAssembly approach has an advantage over JavaScript regarding CPU usage and stable FPS.

In terms of file size, the JavaScript solution is more compact, and as a result, the loading time is faster than WebAssembly for the current project.

From project to project, all factors should be taken into account. In some circumstances, loading time is more crucial than calculation speed, while in others, performance is more important than loading time.

There are numerous other factors to consider. WebAssembly requires knowledge of a low-level language such as C/C++ or Rust, which prevents WebAssembly from being as popular as JavaScript.

To sum up, use JavaScript as long as it works, and use WebAssembly when a performance boost is required.

Resources

--

--

Leo
Source True

JavaScript/TypeScript developer. In past ActionScript 3.0. https://stesel.netlify.app/