2D Web Rendering with Rust

Tom Lagier
Studio Lagier
Published in
6 min readMay 21, 2021
Like animals, all mascots eventually evolve into crabs

I’m playing around with a new project, building a simple web game.

Because this is a for-fun project and not a try-hard-to-build-something-useful project, I thought I’d kick the tires on Rust in the web environment. There’s a whole lot of people out there looking forward to the day where they can write performant UI code without touching a line of JS, and I wanted to get a feeling for the state of the ecosystem.

My goal is a simple 2D game drawn to the canvas. It needs to handle input, play audio, and most importantly, render at least 60 FPS, even on mobile devices.

TL;DR

Here’s a quick summary of the results:

Given the performance gap between Rust and native and the ergonomics of using Rust in JS, I would recommend against using Rust for rendering in 2D on the web.

Getting started

To get started, I usedwasm-pack and wasm-bindgen to quickly bootstrap a WebAssembly-ready project. The tools are great, and the introductory docs are a breeze to get going with.

I thought of a couple of approaches to integrating Rust into the game. The first is the most hands-off — we’d use Rust to run the client-side game state engine, but leave all rendering, audio, and input-handling to the browser. This is the approach suggested by the excellent rust-wasm book. It also involves writing the least amount of Rust.

There are a couple of alternatives — I could use web-sys to expose the browser APIs to Rust, and write my JS from Rust. I could also manage a pixel buffer on the Rust side by hand, sharing it with JS and using it to draw to the canvas via putImageData.

In order to decide which approach I wanted to explore further, I decided to extend the example of Conway’s game of life in the rust-wasm book to do each one. This should give me a feel for the ergonomics and performance of each option & help me decide which one is the best fit.

I ended up putting together this repository which builds the same game using a few different drawing libraries & shows the FPS.

The “JS renderer” solution was extremely fast — each frame took <1ms to draw. This is the solution shown in the rust-wasm book, storing the game state in WebAssembly memory and doing all drawing via normal Canvas2DRenderingContext calls. This leverages the web platform for native APIs, and Rust for fast game state management, and should be seen as the benchmark to beat.

2D drawing libraries

First, I used the tiny-skia library to write an all-Rust renderer. tiny-skia is a small Rust port of the Skia 2D graphics library with a nice ergonomic API. It would allow me to manage a pixel buffer and share it with JS. It was also the slowest option I tried.

I took another stab with the piet-web library, which also has a very ergonomic API. Unlike tiny-skia, it renders its output directly to the web platform by generating Canvas2DRenderingContext calls via web-sys . So, we’re pretty much writing JS from Rust with this option. It ended up being quite fast and presents an attractive alternative to pure-JS work.

However, the overhead of needing to pass function arguments across the WASM boundary, plus the additional overhead of an intermediate framework on top of the canvas means that it will never be as fast as pure JS CanvasRenderingContext2D calls.

After trying a few Rust drawing libraries, I decided to see what it would be like to hand-roll a renderer. Conway’s game of life is pretty simple, just lines and squares, so manually managing a pixel buffer should be straightforward, and it would let me keep all of the rendering logic in Rust. The performance of my home-made renderer was good, but not as good as the JS renderer and on par with piet.

Could WebGL be faster?

After looking at 2D options, I decided to try my hand at the 3D ones. winit and wgpu are both WASM- and WebGL-friendly, and they allow us to render to a WebGLRenderingContext in the DOM. Rendering 2D graphics on the GPU seems sensible, and there were a handful of Rust libraries that could do the translation — I investigated lyon, the piston2d game engine, and pathfinder .

This ended up being an enormous time sink. Getting Rust 3D libraries to play nicely with the browser is not a trivial task. While winitand wgpu might work well with wasm-bindgen, the support from other libraries was just not quite there.

I tried both piston2d and lyon and didn’t have luck with either. There have been WASM proof of concepts with both, but always using emscripten rather than wasm-bindgen. Emscripten is an incredible tool, but I wanted to try and get things working with wasm-bindgen for this project.

wasm-bindgen vs emscripten

The major difference between the two tools is that emscripten provides the standard library, and does a lot of heavy lifting to port functionality between native code and the web — it will handle the bindings for TCP, file IO, multithreading, and OpenGL. So, we can use it to compile native applications for the web but the framework is doing a lot of heavy lifting to translate native APIs into web ones.

wasm-bindgen, on the other hand, is much more lightweight and requires the applications themselves to define how they bind to the web platform. This produces smaller, more purpose-built WASM at the expense of broad compatibility with native code.

Many projects are building WebGL, and thus wasm-bindgen support directly. See this issue, which would unlock piston2d support. Unfortunately, it can be really difficult to determine the level of support in other libraries without doing a lot of experimental building and debugging.

Trying to get the integration between lyon and wgputo work on the web ended up with many-MB bundle sizes and no working application, though likely that is because I don’t fully understand the necessary wgpusetup.

I did manage to get the pathfinder library successfully building, but its performance was a little disappointing — faster than the slowest 2D renderer, but off by a magnitude of 2 from piet, and 5 from the JS renderer.

So, overall, 3D was a bust. I’m sure if I had the expertise building graphics pipelines I could put together something that worked well, but right now it just doesn’t seem like an efficient use of time for fairly mediocre performance.

Other considerations

There are a couple of other usability concerns with Rust in JS, beyond library support and rendering speed. The biggest one is debugging — while WASM debugging has gotten a lot better, we still can’t get meaningful step debugging, which means that we’re stuck with print debugging.

Additionally, because of the translation layer between Rust and the web, there is a little extra friction around things like analyzing an error stack, a perf flame graph, memory usage, or inspecting app state.

Finally, just like on the web, we need to be vigilant about bundle sizes. piet bundled to 72kb, but tiny-skia was 377kb and pathfinder clocked in at a whopping 718kb.

Overall, I had a lot of fun building out these demos. I learned a lot about Rust and WebAssembly, and fully intend to use Rust to manage my game state.

Given the gap in performance between Rust and native web, the challenges around finding web-compatible libraries, and the extra development friction that using Rust on the web adds, I won’t be using it to handle my rendering.

I do think that there is a lot of opportunity for building cross-platform apps in Rust and using emscripten to compile for the web as one of several different targets. This might not produce the most optimized output for the web, but you get it essentially for free.

If you’re building primarily for the web platform, the pleasant coding environment Rust provides just isn’t going to be worth the tradeoffs.

--

--

Tom Lagier
Studio Lagier

Tech enthusiast in Los Osos — graphics in the front-end is what I like to work on. Founder @StudioLagier