← Back to 3D World

Mandelbrot in WebAssembly

Three renderers, one algorithm, byte-identical output — so the only thing the stopwatch measures is the instruction stream.

drag to pan · scroll or pinch to zoom · double-click to zoom in

Renderer
Frame
Throughput
Resolution
Max iterations
Zoom
Centre

Renderer

Detail

Colour

Go to

Benchmark

Renders the current view with every renderer your browser supports, best of three after a warm-up pass. Zoom somewhere detailed first — a view that is mostly interior is decided by the cardioid shortcut rather than by the inner loop.

What this actually is

The kernel is hand-written WebAssembly text — not C or Rust compiled down to it. The whole thing, both renderers plus a logarithm, assembles to bytes. There is no runtime, no allocator and no glue code, because nothing in it needs any.

Three interchangeable implementations run the same escape-time algorithm:

All three are required to produce byte-identical pixel buffers and identical iteration counts, checked on every build across six views chosen to hit the awkward cases. That rule is what makes the benchmark mean anything: without it, “this one is faster” would not establish that the two had done the same work.

Expect a smaller gap than the hype suggests

The inner loop here is four multiplies, three adds and a compare on doubles held in registers — no allocation, no polymorphism, no property lookups. That is precisely the shape a modern JavaScript JIT compiles best, so after warm-up it emits nearly the same machine code the WebAssembly does. On V8 the scalar wasm path tends to land within about 10% of JavaScript.

That is the honest result, and it is more useful than a rigged one. WebAssembly's real wins are elsewhere: no warm-up before peak speed, no deoptimisation cliff, predictable memory, explicit SIMD, and the ability to bring a C or Rust library along. Raw arithmetic throughput on code the JIT already loves is not on that list. Run the benchmark and read your own browser's answer.

Things worth knowing

The cardioid shortcut. The main body of the set and the circle to its left are analytically known to be inside it, so those pixels skip the loop entirely. In the default view that is most of the black area. The throughput figure deliberately does not count the iterations this skips — charging for work never done would inflate the number.

SIMD gains less than 2x. Two adjacent pixels rarely escape on the same iteration, so the pair has to keep looping until both are done. The finished lane is frozen with a bitselect rather than exited, which keeps its final z intact for smooth colouring but means its remaining lane-steps are wasted work.

There is no log instruction. Smooth colouring needs log2(log2|z|), and WebAssembly has no logarithm. The kernel reinterprets the double as an integer, takes the exponent straight out of the IEEE-754 bits, and evaluates the mantissa with a five-term series:

x = 2^e * m,  m in [1,2)   ->   log2(x) = e + log2(m)
t = (m-1)/(m+1)
log2(m) = (2/ln2) * (t + t^3/3 + t^5/5 + t^7/7 + t^9/9)

The JavaScript version does the same thing through a two-element typed-array view rather than calling Math.log2, because the two do not agree in the last bit and byte-equality is the point.

Floating point is not associative. The SIMD path first computed lane 1's coordinate as x0 + scale instead of repeating cx + (px+1-hw)*scale. Algebraically identical; one rounds once and the other twice. In open water that gap is invisible, but on the fractal boundary it lands on a different iteration count, and the deep-zoom test failed until both lanes used the same expression.

The precision floor is real. Past roughly 1013 zoom, adjacent pixels are closer together than a double can distinguish and the image goes blocky. Getting deeper needs arbitrary-precision arithmetic with perturbation theory, which is a different project.

Build

wasm/mandelbrot.wat is the source. It is assembled with wabt, which is itself WebAssembly and runs under Node — so there is no clang, emscripten, Rust or Zig anywhere in the pipeline, and the two .wasm binaries are committed so deploying stays a plain file upload.

Two binaries, because a module containing v128 instructions fails to instantiate as a whole on an engine without SIMD — the scalar function inside would be unreachable too. The page feature-detects SIMD and fetches exactly one of them.

The view is in the URL, so any spot you find is a shareable link.