Zoom into the Mandelbrot set in real time, GPU-rendered in your browser. Adjust iterations, cycle colours, switch to Julia sets, export PNG or video.
Knobs patch the running animation live. A few (marked in the code as baked-in values like particle counts) restart the preview.
Escape-time fractal you can actually dive into. Scroll to zoom.
Your code runs in a sandboxed frame with no access to this page, and it is never sent to a server. three.js demos load the library from jsDelivr; the rest need nothing but the browser.
This is a GPU Mandelbrot renderer. Drag the picture to pan, scroll to zoom, and pull the sliders to change how deep the renderer looks and how the colours cycle. The whole thing is a single WebGL fragment shader running in a sandboxed frame in your browser — no image is fetched from a server, nothing you do is uploaded, and the frame has no access to the page around it. The right–hand panel is not a picture of the code, it is the actual editable source: change a line, hit Run, and the fractal changes.
It renders in real time because every pixel is independent. The GPU runs the same short loop on all of them at once, which is exactly the shape of problem a graphics card is built for.
Take a point on the screen. Treat its two coordinates as a single number with a horizontal part and a vertical part — a complex number, call it c. Now start from zero and apply one rule over and over:
z → z² + c
Square the current number, add c, repeat. That is the entire definition. Two things can happen. Either the value stays bounded forever, wandering around near the origin no matter how many times you repeat — or it eventually starts growing and runs away to infinity. The Mandelbrot set is simply the collection of points c for which it stays bounded. Those are drawn black. Everything else escapes.
"Square a complex number" is less exotic than it sounds. Written out in the coordinates the shader actually uses, it is two multiplications and a subtraction:
z = vec2(z.x*z.x - z.y*z.y, 2.0*z.x*z.y) + c;
You can see that line in the shader source on this page, inside a loop of at most 600 passes. Nothing else in the file is doing the fractal work. All the complexity in the image comes out of repeating those few operations.
The colours are a stopwatch. For every escaping pixel the shader counts how many times it had to apply the rule before the value got large — specifically before |z| passed 16, which is the bailout radius this renderer uses — and paints the pixel according to that count. Points that escape almost immediately get one end of the palette; points that cling on for hundreds of iterations before finally flying off get the other. Points that never escape within your iteration budget are black.
So the bands you see are contour lines of escape speed. They crowd together near the boundary of the set because that is where the behaviour changes fastest, and they are what makes the edge look infinitely fringed rather than like a drawn outline.
There is one refinement worth knowing about, because it is the difference between a good fractal renderer and a crude one. A raw integer iteration count produces visible stair–steps: every pixel that escaped on pass 47 is one flat colour, every pixel that escaped on pass 48 is another. This renderer instead computes a continuous escape count, n + 1 − log(log|z|)/log 2, which turns the integer into a smoothly varying fraction using how far past the bailout radius the value overshot. The contours then blend into each other instead of banding. The colour itself comes from a cosine palette — three cosine waves at different phase offsets for red, green and blue — which is why the gradient loops seamlessly instead of hitting a hard seam when it wraps.
Press Controls under the preview to open the panel. These are the knobs, and their real ranges:
| Control | Range | Default | What it does |
|---|---|---|---|
| Drag to pan / scroll to zoom | on / off | on | Enables mouse interaction on the canvas. Turn it off if scroll–wheel zoom is fighting your page scrolling. |
| Zoom | 0.1 – 400 | 0.32 | Magnification. The slider reaches far deeper than the wheel does — see below. |
| Iterations | 30 – 600 | 180 | How many times the rule is applied before the renderer gives up and calls a pixel "inside". |
| Colour cycles | 0.5 – 12 | 3 | How many times the palette repeats across the escape range. High values give tight rainbow banding, low values give broad washes. |
| Hue | 0 – 360 | 0 | Rotates the palette. Purely cosmetic; it does not change which pixels are in the set. |
| Julia set (animated seed) | on / off | off | Switches the rendering rule — see the Julia section. |
| Seed speed | 0 – 1 | 0.2 | How fast the Julia seed travels around its circle. Set it to 0 to freeze on one shape. |
Two things about zoom that will save you confusion. First, the scroll wheel is clamped: each notch multiplies zoom by about 1.12, but the wheel will not take you below 0.15 or above 12. To go deeper than that you drag the Zoom slider, which goes to 400. Second, zooming is anchored to the centre of the canvas, not to your cursor. The working habit is therefore: drag the feature you want until it sits in the middle of the frame, then zoom. Trying to zoom toward a corner just pushes your target off the edge.
Around the preview there are four more buttons that are not fractal controls but are the reason people stay: Save PNG grabs the current frame, Record captures the canvas to a WebM video until you stop it, Fullscreen gives the shader the whole screen, and Console shows anything the code logs or any error it throws if you have been editing.
The boundary of the set is infinitely detailed, and the iteration count is your budget for resolving it. This is the single control most people leave alone and then wonder why their deep zoom looks like mud.
Here is the mechanism. To decide a pixel is outside, the renderer only has to watch it escape — which for most pixels happens quickly. To decide a pixel is inside, it has to fail to escape for the entire budget, and then give up and call it black. Near the boundary there are enormous numbers of points that escape only after hundreds of iterations. At a low budget, all of those get lumped in with the genuinely-inside points and painted black. The fine filigree you came to see is drawn as a solid blob.
So: as you zoom in, raise iterations. At the default view 180 is plenty and costs nothing. At a zoom in the tens you will want to be pushing toward the 600 ceiling, and you may notice the frame rate drop, because the cost is roughly linear in the iteration count on every pixel at once. If detail is missing, raise it; if the animation is stuttering and you are not zoomed far in, lower it.
Keep zooming and at some point the smooth curves dissolve into flat rectangular tiles. That is not the renderer failing to iterate enough — raising the iteration count will not fix it.
It is arithmetic precision running out. The shader works in single–precision 32–bit floating point, which carries roughly seven significant decimal digits. Each pixel's c is computed from the screen coordinate divided by the zoom, so as zoom grows, adjacent pixels are asked to represent complex numbers that differ in the eighth or ninth significant digit. Eventually they cannot: neighbouring pixels round to the identical value, get the identical iteration count, and render as one flat block. The picture stops being wrong in a subtle way and starts being visibly quantised.
Every GPU fractal renderer hits this wall. Going deeper requires software arithmetic — double–double or arbitrary precision, with perturbation techniques to keep it affordable — which is a fundamentally different and much slower program. Deep–zoom fractal videos that dive to magnifications of 10100 are made that way, offline, over hours. A live shader is trading depth for the fact that it responds instantly.
The default view is centred at −0.5 on the real axis, which frames the whole set. From there, the landmarks:
c whose orbit settles down to a single fixed value.−0.75. This is the best first zoom on the whole set: the spiral seahorse tails get more elaborate the further you go and the structure never repeats exactly.−2. It looks one–dimensional at the default zoom and is not.A good sequence for a first session: leave iterations at 180 and drag until the cardioid/bulb pinch is centred, wheel–zoom in a few notches, raise iterations to about 400, then switch to the Zoom slider and keep centring the feature you want before each step.
Flipping Julia set changes which quantity the pixel represents. In the Mandelbrot rendering, each pixel is a different c and every orbit starts at zero. In the Julia rendering, the whole image shares one single c — the seed — and each pixel is a different starting z. Same iteration rule, same escape–time colouring, opposite question.
The seed here is not fixed: it travels around a circle of radius 0.7885 at a rate you set with Seed speed, so the shape morphs continuously as you watch. Set the speed to 0 to hold one form still. The two pictures are tied together — a seed taken from inside the Mandelbrot set gives a connected Julia set, and a seed from outside gives one that has shattered into dust. That correspondence is the reason the Mandelbrot set matters mathematically rather than just decoratively: it is a map of which Julia sets are connected.
The panel on the right has HTML, CSS, JS and EXPLAIN tabs. The fractal lives in the JS tab, as a GLSL fragment shader inside a template string, alongside the small amount of WebGL plumbing that compiles it and draws a single full–screen triangle. Everything is legible; there is no framework in the way.
Things worth trying: change the bailout test from dot(z, z) > 256.0 to a smaller threshold and watch the smooth colouring break down, because the continuous escape formula assumes the value has comfortably overshot. Swap the palette's phase offsets vec3(0.0, 0.6, 1.0) for other values to get an entirely different colour scheme. Or change the exponent — cube z instead of squaring it and you get a multibrot, which has two–fold rotational symmetry instead of the single cardioid (an exponent of d gives d − 1 lobes).
This renderer is one preset of a larger animation playground; the gallery under the preview switches to the other WebGL, canvas, CSS and three.js demos, all with their source open the same way.
Everything you see is one fragment shader. The GPU runs it once per pixel, with thousands of pixels evaluated in parallel, and nothing is stored between frames — the colour of a pixel is a pure function of its coordinates, the zoom, and the iteration budget. That is why panning stays interactive instead of taking seconds per frame the way a CPU renderer does.
GLSL has no complex number type, so z^2 + c is expanded by hand into a two-component vector:
z = vec2(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y) + c;
The real part is x^2 - y^2 and the imaginary part is 2xy. That single line, repeated, is the entire Mandelbrot set.
for (int k = 0; k < 600; k++) {
if (float(k) >= uIter) break;
...
}
WebGL 1 requires loop bounds to be compile-time constants — you cannot write k < uIter where uIter is a uniform, because the shader compiler needs to be able to unroll the loop. The workaround is to loop to a fixed maximum and break early on the real limit. The constant 600 is therefore a hard ceiling on the iteration slider, not an arbitrary number.
The escape test is written as dot(z, z) > 256.0 rather than length(z) > 16.0. Both say the same thing, but length computes a square root and this runs on every iteration of every pixel. Comparing squared magnitudes avoids millions of unnecessary square roots per frame.
The generous escape radius of 16 (rather than the mathematically sufficient 2) also improves the smooth-colouring formula: the continuous escape estimate is more accurate the further past the boundary the point has travelled when you sample it.
GPUs work in 32-bit floating point, which carries roughly 7 significant decimal digits. Every pixel is computed from screen position / zoom, so as the zoom climbs, adjacent pixels start resolving to the same representable number. Around 10^6 to 10^7 magnification the image goes blocky and stops gaining detail no matter how many iterations you allow.
Renderers that go deeper use double precision (available on some hardware at a large speed penalty) or arbitrary-precision arithmetic combined with perturbation theory, where one high-precision reference orbit is computed on the CPU and nearby pixels are calculated as small offsets from it. That is how the famous deep-zoom videos are made, and it is far too slow to be interactive.
Cost scales with pixels multiplied by iterations. Doubling the iteration slider roughly halves the frame rate; so does moving to a display with twice the pixel ratio. Points inside the set are the expensive ones, because they never escape and always run the full loop — which is why zooming into a large black region feels heavier than skimming the boundary.
If the preview stutters, drop the iterations before anything else. You will lose fine boundary detail and gain frame rate immediately.
Points that never escape are drawn black because the escape count carries no information for them. There are other conventions — colouring by how close the orbit came to the origin, or by the period of the cycle it settled into — which reveal structure inside the set rather than a flat silhouette.
Until 32-bit floating point precision runs out on the GPU, which in practice is somewhere around 10^6 to 10^7 magnification depending on your hardware. Past that the image goes blocky - neighbouring pixels compute identical values because the numbers can no longer represent the difference between them. That is a hardware limit, not a bug. Renderers that go deeper switch to double precision or arbitrary-precision arithmetic, which is far slower.
You need more iterations. The boundary of the set is infinitely detailed, and the iteration count is your budget for resolving it - too few and the renderer gives up before it can tell an escaping point from a trapped one, so everything blurs into a single band. Raise the Iterations slider as you descend. The trade-off is frame rate, since every pixel runs the loop.
They are two views of the same equation. For the Mandelbrot set, each pixel supplies the constant c and the sequence always starts at zero. For a Julia set, c is one fixed value for the whole image and each pixel supplies the starting z. Every point in the Mandelbrot set corresponds to a Julia set that is connected; every point outside corresponds to one that is scattered dust. Turning on the Julia toggle animates c around a circle so you can watch the family morph.
Yes. Save PNG captures the current frame at the canvas resolution, and Record captures a WebM video, which is the better choice for a zoom or a Julia morph. Both happen entirely in your browser - nothing is uploaded.
Yes. The whole image is a fragment shader, so the escape-time loop runs once per pixel with thousands of pixels computed in parallel on the GPU. That is why panning and zooming stay interactive instead of taking seconds per frame the way a CPU renderer would.
Edit and run 20 canvas, WebGL, three.js and CSS animations live in your browser.
Draw spirograph and harmonograph figures in your browser. Adjust the frequency ratio, radii and damping, then save a PNG.
Generate the Matrix digital rain effect in your browser. Tune speed, glyphs, colour and trail length, then export a PNG, video or standalone HTML file.