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
At this depth a 64-bit double has run out of significant digits. The blockiness is the number format, not the renderer.
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.
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:
f64x2, two
horizontally adjacent pixels per lane step.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.
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.
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.
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.