Rendering 2D Vector Graphics with Wasm

This page answers one task: an application draws vector graphics — charts, diagrams, map tiles, a design tool’s canvas — and needs identical, deterministic rendering across browsers and servers, so it rasterizes paths in a WebAssembly module and shows the pixels on a <canvas>.

Prerequisites

  • [ ] A Rust or C++ rasterizer that compiles to Wasm: tiny-skia (Rust), Skia via CanvasKit, Blend2D, or a custom scanline renderer.
  • [ ] A <canvas> element and the 2D context.
  • [ ] Familiarity with sharing a canvas framebuffer with Wasm.

Why rasterize in Wasm when the browser has Canvas 2D

The browser’s Canvas 2D API already draws paths quickly, often with GPU acceleration. Rasterizing in Wasm is not about raw speed; it is about control. Canvas output differs between browsers and platforms — antialiasing, gamma, hairline strokes, text — so pixel-exact tests and server-side rendering that must match the client are hard. A rasterizer compiled to Wasm produces the same pixels everywhere, can run in a worker without OffscreenCanvas, runs on the server unchanged for thumbnails or PDF previews, and supports features Canvas lacks, such as custom blend modes or analytic coverage for specific shapes.

The pipeline is simple: the module owns an RGBA pixel buffer in linear memory, draws paths into it, and JavaScript copies that buffer to the canvas with putImageData — or the module renders in a worker and transfers an ImageBitmap.

From vector paths to pixels on a canvas JavaScript sends a scene of paths and styles to the Wasm module. The rasterizer fills and strokes the paths into an RGBA pixmap in linear memory. JavaScript wraps that memory in ImageData, after converting from premultiplied alpha, and draws it to the canvas with putImageData. scene description paths, fills, strokes rasterizer in Wasm antialiased coverage RGBA pixmap in linear memory ImageData view unpremultiply if needed putImageData pixels on screen

Step 1 — draw into a pixmap with tiny-skia

tiny-skia is a pure-Rust subset of Skia’s CPU rasterizer, small and dependency-free, which makes it a good fit for Wasm:

use tiny_skia::*;
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub struct Canvas2D { pixmap: Pixmap }

#[wasm_bindgen]
impl Canvas2D {
    #[wasm_bindgen(constructor)]
    pub fn new(width: u32, height: u32) -> Canvas2D { Canvas2D { pixmap: Pixmap::new(width, height).unwrap() } }

    pub fn clear(&mut self) { self.pixmap.fill(Color::WHITE); }

    pub fn circle(&mut self, cx: f32, cy: f32, r: f32, rgba: u32) {
        let mut pb = PathBuilder::new();
        pb.push_circle(cx, cy, r);
        let path = pb.finish().unwrap();
        let mut paint = Paint::default();
        paint.set_color_rgba8((rgba >> 24) as u8, (rgba >> 16) as u8, (rgba >> 8) as u8, rgba as u8);
        paint.anti_alias = true;
        self.pixmap.fill_path(&path, &paint, FillRule::Winding, Transform::identity(), None);
    }

    pub fn ptr(&self) -> *const u8 { self.pixmap.data().as_ptr() }
    pub fn len(&self) -> usize { self.pixmap.data().len() }
}

Batch drawing calls: crossing the boundary per primitive is fine for hundreds of shapes, but for tens of thousands, send the whole scene as one buffer — a compact command list of path verbs and coordinates — and let the module draw it in one call.

Keep colours and coordinates as plain numbers at the boundary, as above, so each call costs only a few nanoseconds of argument passing.

Step 2 — display the pixmap on a canvas

const c = new Canvas2D(width, height);
c.clear();
for (const p of points) c.circle(p.x, p.y, 3, 0x1d4ed8ff);

const pixels = new Uint8ClampedArray(memory.buffer, c.ptr(), c.len());
ctx.putImageData(new ImageData(pixels, width, height), 0, 0);

ImageData accepts a view over Wasm memory directly, so no copy is made before putImageData, which itself copies into the canvas. Create the view after drawing, since drawing may allocate and grow memory, as discussed in creating views into Wasm memory safely.

Step 3 — handle premultiplied alpha

tiny-skia and most rasterizers store premultiplied RGBA: each colour channel is already multiplied by alpha. ImageData expects unpremultiplied values. For opaque output — a white background with shapes on it — the two are identical and nothing is needed. For transparent output, convert before display, ideally in Wasm with a SIMD loop, or draw with createImageBitmap(imageData, { premultiplyAlpha: "premultiply" }) choices that match the data. Skipping the conversion makes semi-transparent edges look dark, an artefact that is easy to miss on white backgrounds and obvious on coloured ones.

Premultiplied versus straight alpha Rasterizers usually produce premultiplied colour, where channels are scaled by alpha. Canvas ImageData expects straight alpha. Opaque images look the same in both; semi-transparent pixels must be converted or edges render too dark. premultiplied (rasterizer) rgb already × alpha correct blending maths what tiny-skia stores convert before ImageData straight (ImageData) rgb independent of alpha what putImageData expects opaque pixels identical target format for display

Step 4 — render sharply on high-DPI screens

A canvas whose backing store matches its CSS size looks blurry on a 2× or 3× display. Size the pixmap in device pixels and scale the drawing:

const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(cssWidth * dpr);
canvas.height = Math.round(cssHeight * dpr);
const c = new Canvas2D(canvas.width, canvas.height);
c.set_transform(dpr, 0, 0, dpr, 0, 0);                    // module applies Transform::from_row(dpr, 0, 0, dpr, 0, 0)

Remember that device pixels quadruple the pixmap’s memory at 2× — a 1600×1000 CSS canvas becomes 3200×2000 × 4 bytes = 25.6 MB of linear memory.

Step 5 — render off the main thread

For heavy scenes, run the module in a worker. The worker draws, creates an ImageBitmap from the pixels, and transfers it:

// worker
const bmp = await createImageBitmap(new ImageData(pixels.slice(), w, h));
self.postMessage(bmp, [bmp]);
// main thread
ctx.drawImage(bitmap, 0, 0);

Or give the worker an OffscreenCanvas transferred from the page and let it call putImageData itself, avoiding the bitmap hop. Either way, the main thread only composites.

Incremental redraws and dirty rectangles

Interactive vector applications rarely change the whole picture at once: a user drags one shape, hovers over one point, edits one label. Redrawing the entire pixmap for each change wastes time that grows with the canvas size. Track a dirty rectangle — the union of the old and new bounding boxes of everything that changed — and redraw only shapes intersecting it, with a clip set to that rectangle so nothing outside is touched. Then copy only that region to the canvas with the dirty-rectangle form of putImageData(imageData, 0, 0, x, y, w, h). For scenes with many shapes, a spatial index in the module — a grid or an R-tree over shape bounds — makes finding the shapes that intersect the dirty region cheap. Layering helps too: render the static background once into its own pixmap, keep moving elements on a separate overlay canvas, and composite with CSS. With these techniques, an interaction that would cost a full 15 ms redraw typically costs well under a millisecond, which keeps dragging and hovering smooth even with large documents.

When to use Canvas 2D or the GPU instead

CPU rasterization in Wasm is the right tool for determinism, server parity and moderate scene sizes. It is the wrong tool for full-screen animation of complex scenes at 60 frames per second: filling a 4K pixmap every frame is memory-bandwidth bound, and the upload to the canvas adds more. For that, draw with Canvas 2D directly, or use the GPU — WebGL or WebGPU — either from JavaScript or from Wasm through bindings, as in driving WebGPU from Rust Wasm. GPU vector renderers such as Vello compute coverage in compute shaders and handle huge scenes in real time. A common hybrid uses Wasm for geometry — tessellation, path simplification, hit testing, layout — and the browser or GPU for pixels. Choose based on which constraint dominates: identical output, or frame rate.

Expected output

A scatter plot of 20,000 antialiased points renders into an 1800×1200 device-pixel pixmap in about 14 ms and appears on the canvas via putImageData; the same module produces a byte-identical PNG on the server; and on a 2× display the edges are crisp.

Gotchas

  • Blurry output on high-DPI screens. Size the pixmap in device pixels.
  • Dark fringes on transparent shapes. Premultiplied data shown as straight alpha. Convert.
  • Stale views after drawing. Drawing may grow memory. Create the ImageData view afterwards.
  • One boundary call per shape for huge scenes. Batch commands into one buffer.
  • Redrawing everything for small changes. Track dirty rectangles and redraw only what changed.
  • Full-screen animation on the CPU. Bandwidth-bound. Use Canvas 2D or the GPU for real-time animation.

Performance note

Drawing 20,000 antialiased circles took 14 ms in tiny-skia compiled to Wasm with SIMD, 9 ms with Canvas 2D in Chrome, and 21 ms with a JavaScript rasterizer. putImageData for the 8.6 MB pixmap took about 3 ms.

Drawing 20,000 antialiased circles at 1800×1200 Milliseconds to render twenty thousand antialiased circles with Canvas 2D in Chrome, tiny-skia compiled to Wasm with SIMD, and a pure JavaScript rasterizer. ms per frame Canvas 2D (browser) 9 ms tiny-skia in Wasm (SIMD) 14 ms JavaScript rasterizer 21 ms

Frequently Asked Questions

What about text? Text shaping and glyph rasterization need a font engine (for example rustybuzz and a glyph rasterizer); budget for their size, or draw text with Canvas.

Is CanvasKit an option? Yes — it is full Skia in Wasm with GPU support, much larger (several megabytes) but very capable.

Can I export SVG or PDF too? Keep the scene description independent of the rasterizer, and add exporters that write vector formats from the same data.

How do I hit-test shapes? Do it in Wasm against the same paths, so clicks match exactly what was drawn.

Does the same code run on the server? Yes — compile the rasterizer natively or run the Wasm module in Node, and encode the pixmap as PNG for thumbnails or exports.

How do I test rendering? Render reference scenes and compare against stored PNGs byte for byte, which deterministic CPU rendering makes possible.

← Back to Graphics, Games & Simulation