Map: the Mandelbrot set. Drag on it to choose c (orange); pinch or scroll to zoom it.Mapa: el Mandelbrot. Arrastra sobre él para elegir c (naranja); pellizca o usa la rueda para acercarlo.

The idea in plain words

The Mandelbrot set and Julia sets use the same rule, square the number and add c, but ask different questions. For the Mandelbrot set, every pixel is a different c and the sequence always starts at zero. For a Julia set, c is fixed for the whole picture and every pixel is a different starting point. The starting points whose sequence stays small are painted black: that is the filled Julia set. Its edge is the Julia set proper. Change c and you get a different picture.

Think of the edge as a watershed. A raindrop that lands on one side of a ridge flows into one valley; on the other side, into another. Here the two "valleys" are staying small and running off to infinity, and the Julia set is the ridge between them. The analogy has limits: a real ridge is a smooth line and the drops near it behave calmly. This ridge is a fractal, and on it the behaviour is chaotic: two starting points as close as you like can end up anywhere along it.

The deep connection is that the Mandelbrot set is a map of all Julia sets. Each point of the map is one value of c, so one Julia set. If c is inside the black shape, its Julia set is one connected piece. If c is outside, the Julia set shatters into dust. Like the map of a city, where each address is one house. That analogy undersells it: near certain addresses, the house looks like the map around it.

Drag on the little map and watch the big picture follow. Inside the main body, Julia sets are bent circles. In the bulbs they sprout arms: the bulb on top gives the three-eared "rabbit". Drag across the border and the picture falls apart into dust. Or press Walk the cardioid's edge and let c travel along the border of the main body on its own.

Why it matters

Gaston Julia and Pierre Fatou developed this theory around 1918 to 1920, decades before anyone could draw it. Julia, badly wounded in the First World War, won the Paris Academy's Grand Prix for it in 1918. They had no pictures, only proofs. Computer images around 1980, Mandelbrot's among them, brought their work back.

The surprise is how much one orbit decides. To know whether a whole Julia set, infinitely detailed, is one piece or dust, you only follow the sequence that starts at 0. The picture is another example of emergence: the same napkin rule, and one number c, decide a shape no one drew.

The catch is that many Julia sets have no area at all. Dendrites are trees of curves with no thickness, and dust is points. The centre of a pixel almost never lands exactly on them, so the usual colouring misses them. Drawing them sharply needs a different tool (step 7).

Is there a theorem?

  • Connected or dust. If the orbit of 0 stays bounded, the Julia set is connected. If it escapes, the Julia set breaks into uncountably many pieces (Milnor states this as Theorem 17.3, "essentially due to Fatou and Julia"). For z² + c those pieces are single points: a Cantor set, the "dust". There is nothing in between.
  • So the Mandelbrot set is the index. The Mandelbrot set is defined as the c whose orbit of 0 stays bounded. By the theorem above, it is exactly the set of c whose Julia set is connected.
  • Near some points, the map and the territory look alike (Tan Lei, 1990). At a Misiurewicz point, a c whose orbit of 0 ends up on a repelling cycle, zooming into the Mandelbrot set and into that Julia set shows the same shapes, up to a rotation and a change of scale. c = i is one: 0 → i → −1 + i → −i → −1 + i → … Try Mandelbrot ≈ Julia at c = i.
  • Some orbits rotate forever (Siegel, 1942). For certain c on the edge of the main body, a region of the Julia set's inside turns like a wheel, by an angle that never repeats. Try Siegel disk and tap inside it: the orbit draws a closed ring.
  • Still open: whether the Mandelbrot set is locally connected (see the Mandelbrot page), one of the central open problems about this map of Julia sets.

The equations

  • The rule: zn+1 = zn² + c, with z0 = the pixel and c fixed
  • Filled Julia set: Kc = { z0 : the orbit stays bounded }; the Julia set Jc is its boundary
  • Escape: once |zn| > max(2, |c|), the orbit escapes
  • Dichotomy: c ∈ M ⇔ Jc connected; c ∉ M ⇒ Jc is a Cantor set
  • Derivative: z′n+1 = 2·zn·z′n, with z′0 = 1
  • Distance to J: d ≈ |zn|·log|zn| / |z′n|, right up to a factor of two

Read the main line as: start at the pixel, square, add the fixed c, repeat. The last two lines say how fast a small step at the start grows along the orbit, and turn that into how far the pixel is from the set.

What the picture does and does not say

  • Black means "did not escape within the iteration limit", or "came back to where it was" when Detect cycles is on. Points extremely close to the Julia set can be misjudged either way.
  • The readout decides "connected" or "dust" by following the orbit of 0 for 10,000 steps. For a c just outside the Mandelbrot set, that may not be enough, and it would say "connected".
  • In Smooth colour, dust can look like solid shapes, because pixels near the dust escape slowly. Distance estimate shows where the set really is, to within about a pixel. Neither can show its individual points: there are infinitely many and they have no size.
  • The Mandelbrot ≈ Julia comparison uses the same zoom for both. Tan Lei's theorem says they match up to a rotation and a scale, and only in the limit; at this zoom they look alike, not identical.
  • There is no perturbation here, so zoom stops at about ×100,000.

How it works

This part builds the Julia explorer on top of the Mandelbrot one. It covers:

  • The one-line change from Mandelbrot to Julia.
  • Sharing the GPU plumbing between two fractal scenes.
  • Two linked views: a map that picks c, and the Julia set that follows.
  • Deciding "connected or dust" with a single orbit.
  • Skipping the black without a formula: cycle detection.
  • Drawing sets with no area: the distance estimator.
  • Staying fluid when every frame must be redrawn.

Plain JavaScript and raw WebGL, no libraries. The scene is about 380 lines, plus a shared module of about 300.

1. Swap the roles

In the Mandelbrot escape-time function, c came from the pixel and z started at 0. For a Julia set, swap them: z starts at the pixel and c is a parameter of the whole picture. Everything else stays.

// Mandelbrot: c is the pixel, z starts at 0.
// Julia:      z starts at the pixel, c is the same for the whole picture.
function juliaEscape(zx, zy, c, maxIter) {
  const r2 = Math.max(4, c.x * c.x + c.y * c.y);   // escape radius max(2, |c|), squared
  let x = zx, y = zy;
  for (let n = 0; n < maxIter; n++) {
    [x, y] = [x * x - y * y + c.x, 2 * x * y + c.y];   // z = z² + c
    if (x * x + y * y > r2) return n;
  }
  return maxIter;   // never escaped: in the filled Julia set (as far as we know)
}

Switch to Inside / outside: this test, and nothing else, draws that picture.

Why max(2, |c|) and not just 2?

The Mandelbrot proof used |z| ≥ |c|, which was automatic there because only c with |c| ≤ 2 matter. Here c can be anything on the map. If |z| > 2 and |z| ≥ |c|, then |z² + c| ≥ |z|² − |c| ≥ |z|(|z| − 1) > |z|, and the growth speeds up. So the radius must be at least |c| as well.

2. Into the shader

The fragment shader changes in the same place. The pixel becomes the starting z, and c arrives as a uniform, the same for every pixel. Changing c is a single gl.uniform2f call, which is what makes the live drag possible.

uniform vec2 uC;          // the same c for every pixel
…
void main() {
  vec2 p = (gl_FragCoord.xy - 0.5 * uRes) / uRes.y;
  vec2 z = uCenter + p * uHeight;      // the pixel is the starting point
  …
  for (int i = 0; i < 5000; i++) {
    if (float(i) >= uMaxIter) break;
    …
    z = vec2(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y) + uC;
    if (dot(z, z) > bail * bail) { escaped = true; break; }
    n += 1.0;
    …
  }
  …
}

3. Sharing the plumbing

The Mandelbrot script already solved sizing, panning and pinching, adaptive resolution, frame timing and recovery from a GPU reset. With a second fractal, and more planned, copying that code would mean fixing every bug twice. So it moved into one module, explainer/fractal-view.js. A view is a WebGL canvas, a 2D canvas on top and a region of the plane. The scene gives it a shader and a few callbacks.

const julia = createView({
  canvas: $("ju-canvas"),
  overlay: $("ju-orbit"),
  frag: JULIA_FRAG,
  home: JULIA_HOME,
  render(gl, v) {
    gl.uniform2f(v.loc("uC"), state.c.x, state.c.y);
    gl.uniform1f(v.loc("uMaxIter"), state.maxIter);
    …
  },
  drawOverlay: drawJuliaOverlay,
  onTap(p) { state.orbit = p; julia.requestOverlay(); },
});

const map = createView({
  canvas: $("ju-map"),
  overlay: $("ju-map-marks"),
  frag: MAP_FRAG,
  home: MAP_HOME,
  drawOverlay: drawMapOverlay,
  onPick(p) { stopWalk(); setC(p); },
});

The Mandelbrot scene now uses the same module, and its script shrank from about 500 to about 285 lines. The map is just a second view, with one difference: onPick. When it is set, a one-finger drag reports points of the plane instead of panning. Pinch and wheel still zoom the map.

4. Two linked views

Picking c on the map changes every pixel of the Julia set, so it is the same as moving the view: we call moved(), which renders at low resolution while the finger moves and at full resolution once it stops. The map itself does not change, only the orange marker on its overlay.

// Changing c changes every pixel of the Julia set: redraw it at low resolution while c moves.
function setC(p) {
  state.c = { x: p.x, y: p.y };
  julia.moved();
  map.requestOverlay();
}

5. Connected or dust

By the Fatou–Julia dichotomy, one orbit settles the question for the whole picture: the orbit of 0. The readout follows it on the CPU for up to 10,000 steps. If it escapes, the set is dust; if not, it is connected. This is also the Mandelbrot test, which is exactly why the map works.

// The orbit of 0 (the critical point) decides the shape of the whole Julia set.
function criticalEscape(c, maxIter) {
  let x = 0, y = 0;
  for (let i = 0; i < maxIter; i++) {
    [x, y] = [x * x - y * y + c.x, 2 * x * y + c.y];
    if (x * x + y * y > 4) return i + 1;
  }
  return 0;   // still bounded
}

The blue cross on the Julia set marks 0. Pick a c just outside the map's black shape: the readout switches to dust, and the picture breaks apart around the cross first.

Why is 0 special?

0 is the critical point of z² + c: the only place where the map folds the plane (its derivative, 2z, is zero there). Fatou and Julia showed that each attracting cycle captures a critical point, so the fate of the critical points controls the whole set. A quadratic map has only one, at 0.

6. Black for free: cycle detection

The Mandelbrot scene skipped its slowest pixels with a formula for the main cardioid. Julia sets have no such formula: the black shape changes with every c. But points inside usually fall into a cycle, a loop of a few values that repeats forever. Once a pixel's orbit returns to a value it had before, it can never escape, and we can stop.

We cannot store the whole orbit on a GPU, so we keep one saved point and compare every step with it. The saved point is replaced after 1, 2, 4, 8… steps. This is Brent's cycle-detection method (1980): whatever the length of the cycle, the window eventually becomes long enough to contain it.

// Cycle detection (Brent): keep one saved point and compare every step with it. The saved point
// is replaced after 1, 2, 4, 8… steps, so a cycle of any length is eventually caught.
vec2 saved = z;
float lap = 0.0, span = 1.0;
float eps = min(1e-6, 0.05 * pix);
…
  if (uCycles == 1) {
    vec2 d = z - saved;
    if (dot(d, d) < eps * eps) break;   // back where it was: it will never escape
    lap += 1.0;
    if (lap >= span) { saved = z; lap = 0.0; span *= 2.0; }
  }

Tick Show diagnostics and untick Detect cycles. In our test with a software renderer, the rabbit went from 55 to 12 ms per frame and the basilica from 36 to 13. The Siegel disk gained less (72 to 45 ms), because its orbits rotate forever instead of settling. The dendrite and the dust did not change: they have no inside to skip. Your GPU will be faster; the ratios are what matter.

Can it make mistakes?

A point just outside the set can shadow a cycle for a while before leaving, and be marked as inside. The tolerance is tied to the pixel size (and at most 10⁻⁶), so this only happens to points within a tiny fraction of a pixel from the boundary. The colour of that pixel was ambiguous anyway.

7. Sets with no area: the distance estimator

Pick Dendrite, c = i in Inside / outside: almost nothing is drawn, because almost no pixel centre lands exactly on the tree. We need to know not only whether a pixel escapes, but how far it is from the set. If the set passes within a pixel, we paint it.

The trick is to track, alongside z, its derivative with respect to the starting point: how much a tiny nudge at the start is stretched by the orbit. Squaring multiplies it by 2z each step. Near the set, orbits are stretched enormously, so a large derivative means "close". Milnor shows that |z|·log|z| / |z′| gives the distance up to a factor of two; we use half of it, the safe side.

vec2 dz = vec2(1.0, 0.0);   // derivative of z_n with respect to the starting point
…
  if (dot(dz, dz) < 1e30) dz = 2.0 * cmul(z, dz); else huge = true;   // (z²)' = 2·z·z'
  z = vec2(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y) + uC;
…
// Distance estimate (Milnor): the distance to J is |z|·log|z| / |dz| up to a factor of two,
// so half of it is a safe lower bound. In plane units.
float dist = huge ? 0.0 : 0.5 * r * log(r) / length(dz);
float s = dist / pix;                               // in pixels
float edge = 1.0 - smoothstep(0.0, 1.0, s);         // bright within about a pixel of the set

Switch to Distance estimate and visit the dendrite and the Cantor dust. The map uses the same idea, with the derivative taken with respect to c, which is why its thin filaments stay visible at any zoom.

Where does the formula come from?

Outside the filled Julia set there is a "potential" G, like the electric potential around a charged metal plate of that shape: G = lim log|zn| / 2n. It is zero on the set and grows away from it. Distance is roughly G divided by how steeply G changes, and both come from zn and its derivative. Milnor notes that this is the step Newton's method would take to solve G = 0. According to Munafo's Mu-Ency, the method is credited to Thurston and was spread by Peitgen and Richter's book The Beauty of Fractals.

8. Fluid while c moves

Panning the Mandelbrot set only redraws when you move. Here, dragging c or walking the edge changes every pixel every frame, so speed while moving is what you feel. The first version used a fixed half resolution while moving. That is too coarse on a fast GPU and too slow on a weak one. Now the moving resolution adapts: if a moving frame took less than 8 ms, the next one gets 25% more pixels per side; if it took more than 24 ms, 20% fewer.

if (view.moving) {
  // Sharper while moving if frames are cheap, coarser if they are not.
  const f = view.frameMs < MOVE_FAST_MS ? 1.25 : view.frameMs > MOVE_SLOW_MS ? 0.8 : 1;
  moveScale = Math.min(idleScale, Math.max(0.25, moveScale * f));
}
…
view.moved = () => {
  if (!view.moving) { view.moving = true; lastDraw = 0; }
  if (view.scale !== moveScale) setScale(moveScale);
  clearTimeout(settleTimer);
  settleTimer = setTimeout(() => { view.moving = false; setScale(idleScale); view.requestDraw(); }, SETTLE_MS);
  view.requestDraw();
};

The diagnostics show the resolution and the frame rate while moving. The Mandelbrot scene gets the same behaviour through the shared module.

9. The walk

The edge of the main cardioid has a simple formula: c = eiθ/2 − e2iθ/4. These are the c where the fixed point of z² + c is neutral, neither attracting nor repelling. Walking θ from 0 to 2π takes c around the whole edge in a minute. The walk only starts when you press the button.

// The edge of the main cardioid, c(θ) = e^{iθ}/2 − e^{2iθ}/4.
function cardioid(theta) {
  const [a, b] = [Math.cos(theta) / 2, Math.sin(theta) / 2];                // e^{iθ}/2
  const [c2, s2] = [Math.cos(2 * theta) / 4, Math.sin(2 * theta) / 4];      // e^{2iθ}/4
  return [a - c2, b - s2];
}

function walkStep(now) {
  if (!state.walking) return;
  if (walkLast) state.theta += ((now - walkLast) / 1000) * (Math.PI * 2 / WALK_SECONDS);
  walkLast = now;
  const [x, y] = cardioid(state.theta);
  setC({ x, y });
  requestAnimationFrame(walkStep);
}

Along the way the Julia set pinches where bulbs are attached (at angles that are fractions of a turn) and, for many angles in between, passes through Siegel disks like the one in the presets. Every one of these sets is connected, because the edge belongs to the Mandelbrot set.

Going further

  • Perturbation for Julia sets. The rule for the difference is simpler than for the Mandelbrot set, δ′ = 2·Z·δ + δ², with no Δc. But the reference orbit starts at the centre of the view, not at 0, so rebasing needs a second reference, the orbit of 0.
  • Inverse iteration: run z → ±√(z − c) backwards from any point. It lands on the Julia set itself and is very fast, but it misses the inner parts (Milnor, Appendix G).
  • Other powers, z → zd + c, give Julia sets with d-fold symmetry and their own "Multibrot" map.

La idea en palabras simples

El Mandelbrot y los conjuntos de Julia usan la misma regla, elevar el número al cuadrado y sumar c, pero hacen preguntas distintas. En el Mandelbrot, cada píxel es un c distinto y la secuencia siempre empieza en cero. En un conjunto de Julia, c es el mismo para toda la imagen y cada píxel es un punto de partida distinto. Los puntos de partida cuya secuencia se mantiene pequeña se pintan de negro: es el conjunto de Julia lleno. Su borde es el conjunto de Julia propiamente dicho. Si cambias c, cambia la imagen.

Piensa en el borde como una divisoria de aguas. Una gota que cae a un lado de una cresta baja a un valle; al otro lado, a otro. Aquí los dos "valles" son quedarse pequeño y escaparse al infinito, y el conjunto de Julia es la cresta entre ellos. La analogía tiene límites: una cresta real es una línea suave y las gotas cercanas se portan con calma. Esta cresta es un fractal, y sobre ella el comportamiento es caótico: dos puntos de partida tan cercanos como quieras pueden acabar en cualquier lugar de ella.

La conexión profunda es que el Mandelbrot es un mapa de todos los conjuntos de Julia. Cada punto del mapa es un valor de c, o sea, un conjunto de Julia. Si c está dentro de la forma negra, su Julia es una sola pieza conexa. Si está fuera, el Julia se rompe en polvo. Como el plano de una ciudad, donde cada dirección es una casa. La analogía se queda corta: cerca de ciertas direcciones, la casa se parece al plano que la rodea.

Arrastra sobre el pequeño mapa y mira cómo lo sigue la imagen grande. Dentro del cuerpo principal, los Julia son círculos deformados. En los bulbos les salen brazos: el bulbo de arriba da el "conejo" de tres orejas. Cruza el borde y la imagen se deshace en polvo. O pulsa Recorrer el borde del cardioide y deja que c recorra solo el borde del cuerpo principal.

Por qué importa

Gaston Julia y Pierre Fatou desarrollaron esta teoría entre 1918 y 1920, décadas antes de que nadie pudiera dibujarla. Julia, gravemente herido en la Primera Guerra Mundial, ganó con ella el Gran Premio de la Academia de Ciencias de París en 1918. No tenían imágenes, solo demostraciones. Las imágenes por ordenador de hacia 1980, entre ellas las de Mandelbrot, recuperaron su trabajo.

Lo sorprendente es cuánto decide una sola órbita. Para saber si un conjunto de Julia entero, con detalle infinito, es una pieza o polvo, basta con seguir la secuencia que empieza en 0. Es otro ejemplo de emergencia: la misma regla de servilleta y un solo número c deciden una forma que nadie dibujó.

El problema es que muchos conjuntos de Julia no tienen área. Las dendritas son árboles de curvas sin grosor, y el polvo son puntos. El centro de un píxel casi nunca cae justo sobre ellos, así que el coloreado habitual no los ve. Dibujarlos con nitidez exige otra herramienta (paso 7).

¿Hay un teorema?

  • Conexo o polvo. Si la órbita de 0 se mantiene acotada, el conjunto de Julia es conexo. Si se escapa, el Julia se rompe en una cantidad no numerable de piezas (Milnor lo enuncia como Teorema 17.3, "esencialmente debido a Fatou y Julia"). Para z² + c esas piezas son puntos sueltos: un conjunto de Cantor, el "polvo". No hay término medio.
  • Por eso el Mandelbrot es el índice. El Mandelbrot se define como los c cuya órbita de 0 se mantiene acotada. Por el teorema anterior, son exactamente los c cuyo Julia es conexo.
  • Cerca de algunos puntos, el mapa y el territorio se parecen (Tan Lei, 1990). En un punto de Misiurewicz, un c cuya órbita de 0 acaba en un ciclo repulsor, al acercarse al Mandelbrot y a ese Julia se ven las mismas formas, salvo un giro y un cambio de escala. c = i es uno: 0 → i → −1 + i → −i → −1 + i → … Prueba Mandelbrot ≈ Julia en c = i.
  • Algunas órbitas giran para siempre (Siegel, 1942). Para ciertos c del borde del cuerpo principal, una región del interior del Julia gira como una rueda, en un ángulo que nunca se repite. Prueba Disco de Siegel y toca dentro: la órbita dibuja un anillo cerrado.
  • Sigue abierto: si el Mandelbrot es localmente conexo (ver la página del Mandelbrot), uno de los problemas abiertos centrales sobre este mapa de los Julia.

Las ecuaciones

  • La regla: zn+1 = zn² + c, con z0 = el píxel y c fijo
  • Julia lleno: Kc = { z0 : la órbita se mantiene acotada }; el conjunto de Julia Jc es su borde
  • Escape: en cuanto |zn| > máx(2, |c|), la órbita se escapa
  • Dicotomía: c ∈ M ⇔ Jc conexo; c ∉ M ⇒ Jc es un conjunto de Cantor
  • Derivada: z′n+1 = 2·zn·z′n, con z′0 = 1
  • Distancia a J: d ≈ |zn|·log|zn| / |z′n|, correcta salvo un factor dos

La línea principal se lee así: empieza en el píxel, eleva al cuadrado, suma el c fijo, repite. Las dos últimas dicen cuánto crece a lo largo de la órbita un pequeño empujón inicial, y lo convierten en la distancia del píxel al conjunto.

Lo que la imagen dice y lo que no

  • Negro significa "no se escapó dentro del límite de iteraciones", o "volvió a donde estaba" cuando Detectar ciclos está activo. Los puntos extremadamente cerca del Julia pueden clasificarse mal en ambos sentidos.
  • La lectura decide "conexo" o "polvo" siguiendo la órbita de 0 durante 10 000 pasos. Para un c apenas fuera del Mandelbrot puede no bastar, y diría "conexo".
  • En Color suave, el polvo puede parecer formas sólidas, porque los píxeles cercanos al polvo tardan en escaparse. El Estimador de distancia muestra dónde está de verdad el conjunto, con un error de un píxel más o menos. Ninguno puede mostrar sus puntos uno a uno: son infinitos y no tienen tamaño.
  • La comparación Mandelbrot ≈ Julia usa el mismo zoom en las dos vistas. El teorema de Tan Lei dice que coinciden salvo un giro y una escala, y solo en el límite; a este zoom se parecen, no son idénticos.
  • Aquí no hay perturbación, así que el zoom se detiene en torno a ×100 000.

Cómo funciona

Esta parte construye el explorador de Julia sobre el del Mandelbrot. Cubre:

  • El cambio de una línea que lleva del Mandelbrot al Julia.
  • Compartir la fontanería de la GPU entre dos escenas de fractales.
  • Dos vistas enlazadas: un mapa que elige c y el Julia que lo sigue.
  • Decidir "conexo o polvo" con una sola órbita.
  • Saltarse el negro sin fórmula: detección de ciclos.
  • Dibujar conjuntos sin área: el estimador de distancia.
  • Mantener la fluidez cuando hay que redibujar cada fotograma.

JavaScript y WebGL a pelo, sin librerías. La escena tiene unas 380 líneas, más un módulo compartido de unas 300. Los comentarios del código están en inglés.

1. Intercambiar los papeles

En la función de tiempo de escape del Mandelbrot, c salía del píxel y z empezaba en 0. Para un Julia se intercambian: z empieza en el píxel y c es un parámetro de toda la imagen. Todo lo demás se queda igual.

// Mandelbrot: c is the pixel, z starts at 0.
// Julia:      z starts at the pixel, c is the same for the whole picture.
function juliaEscape(zx, zy, c, maxIter) {
  const r2 = Math.max(4, c.x * c.x + c.y * c.y);   // escape radius max(2, |c|), squared
  let x = zx, y = zy;
  for (let n = 0; n < maxIter; n++) {
    [x, y] = [x * x - y * y + c.x, 2 * x * y + c.y];   // z = z² + c
    if (x * x + y * y > r2) return n;
  }
  return maxIter;   // never escaped: in the filled Julia set (as far as we know)
}

Cambia a Dentro / fuera: esta prueba, y nada más, dibuja esa imagen.

¿Por qué máx(2, |c|) y no solo 2?

La demostración del Mandelbrot usaba |z| ≥ |c|, que allí era automático porque solo importan los c con |c| ≤ 2. Aquí c puede ser cualquier punto del mapa. Si |z| > 2 y |z| ≥ |c|, entonces |z² + c| ≥ |z|² − |c| ≥ |z|(|z| − 1) > |z|, y el crecimiento se acelera. Por eso el radio también tiene que ser al menos |c|.

2. Al shader

El fragment shader cambia en el mismo sitio. El píxel pasa a ser la z inicial, y c llega como uniform, igual para todos los píxeles. Cambiar c es una sola llamada a gl.uniform2f, y eso es lo que permite arrastrarlo en vivo.

uniform vec2 uC;          // the same c for every pixel
…
void main() {
  vec2 p = (gl_FragCoord.xy - 0.5 * uRes) / uRes.y;
  vec2 z = uCenter + p * uHeight;      // the pixel is the starting point
  …
  for (int i = 0; i < 5000; i++) {
    if (float(i) >= uMaxIter) break;
    …
    z = vec2(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y) + uC;
    if (dot(z, z) > bail * bail) { escaped = true; break; }
    n += 1.0;
    …
  }
  …
}

3. Compartir la fontanería

El script del Mandelbrot ya resolvía el tamaño, el arrastre y el pellizco, la resolución adaptativa, la medición de fotogramas y la recuperación tras un reinicio de la GPU. Con un segundo fractal, y más en camino, copiar ese código obligaría a arreglar cada fallo dos veces. Así que pasó a un módulo, explainer/fractal-view.js. Una vista es un canvas WebGL, un canvas 2D encima y una región del plano. La escena le da un shader y unas pocas funciones.

const julia = createView({
  canvas: $("ju-canvas"),
  overlay: $("ju-orbit"),
  frag: JULIA_FRAG,
  home: JULIA_HOME,
  render(gl, v) {
    gl.uniform2f(v.loc("uC"), state.c.x, state.c.y);
    gl.uniform1f(v.loc("uMaxIter"), state.maxIter);
    …
  },
  drawOverlay: drawJuliaOverlay,
  onTap(p) { state.orbit = p; julia.requestOverlay(); },
});

const map = createView({
  canvas: $("ju-map"),
  overlay: $("ju-map-marks"),
  frag: MAP_FRAG,
  home: MAP_HOME,
  drawOverlay: drawMapOverlay,
  onPick(p) { stopWalk(); setC(p); },
});

La escena del Mandelbrot usa ahora el mismo módulo, y su script bajó de unas 500 a unas 285 líneas. El mapa es solo una segunda vista, con una diferencia: onPick. Si está definido, arrastrar con un dedo informa de puntos del plano en vez de mover la vista. El pellizco y la rueda siguen acercando el mapa.

4. Dos vistas enlazadas

Elegir c en el mapa cambia todos los píxeles del Julia, así que es lo mismo que mover la vista: llamamos a moved(), que dibuja a baja resolución mientras el dedo se mueve y a resolución completa cuando se detiene. El mapa no cambia; solo el marcador naranja de su capa.

// Changing c changes every pixel of the Julia set: redraw it at low resolution while c moves.
function setC(p) {
  state.c = { x: p.x, y: p.y };
  julia.moved();
  map.requestOverlay();
}

5. Conexo o polvo

Por la dicotomía de Fatou y Julia, una órbita resuelve la pregunta para toda la imagen: la órbita de 0. La lectura la sigue en la CPU durante hasta 10 000 pasos. Si se escapa, el conjunto es polvo; si no, es conexo. Es también la prueba del Mandelbrot, y justo por eso funciona el mapa.

// The orbit of 0 (the critical point) decides the shape of the whole Julia set.
function criticalEscape(c, maxIter) {
  let x = 0, y = 0;
  for (let i = 0; i < maxIter; i++) {
    [x, y] = [x * x - y * y + c.x, 2 * x * y + c.y];
    if (x * x + y * y > 4) return i + 1;
  }
  return 0;   // still bounded
}

La cruz azul del Julia marca el 0. Elige un c apenas fuera de la forma negra del mapa: la lectura pasa a "polvo", y la imagen se rompe primero alrededor de la cruz.

¿Por qué el 0 es especial?

El 0 es el punto crítico de z² + c: el único sitio donde la función dobla el plano (su derivada, 2z, vale cero ahí). Fatou y Julia demostraron que cada ciclo atractor captura un punto crítico, así que el destino de los puntos críticos controla todo el conjunto. Una función cuadrática solo tiene uno, en 0.

6. Negro gratis: detección de ciclos

La escena del Mandelbrot se saltaba sus píxeles más lentos con una fórmula para el cardioide principal. Los Julia no tienen esa fórmula: la forma negra cambia con cada c. Pero los puntos de dentro suelen caer en un ciclo, un bucle de unos pocos valores que se repite para siempre. En cuanto la órbita de un píxel vuelve a un valor que ya tuvo, ya no puede escaparse, y podemos parar.

En una GPU no podemos guardar la órbita entera, así que guardamos un punto y comparamos cada paso con él. El punto guardado se renueva tras 1, 2, 4, 8… pasos. Es el método de detección de ciclos de Brent (1980): sea cual sea la longitud del ciclo, la ventana acaba siendo lo bastante larga para contenerlo.

// Cycle detection (Brent): keep one saved point and compare every step with it. The saved point
// is replaced after 1, 2, 4, 8… steps, so a cycle of any length is eventually caught.
vec2 saved = z;
float lap = 0.0, span = 1.0;
float eps = min(1e-6, 0.05 * pix);
…
  if (uCycles == 1) {
    vec2 d = z - saved;
    if (dot(d, d) < eps * eps) break;   // back where it was: it will never escape
    lap += 1.0;
    if (lap >= span) { saved = z; lap = 0.0; span *= 2.0; }
  }

Marca Mostrar diagnóstico y desmarca Detectar ciclos. En nuestra prueba con un renderizador por software, el conejo pasó de 55 a 12 ms por fotograma y la basílica de 36 a 13. El disco de Siegel ganó menos (de 72 a 45 ms), porque sus órbitas giran para siempre en vez de asentarse. La dendrita y el polvo no cambiaron: no tienen interior que saltarse. Tu GPU será más rápida; lo que importa son las proporciones.

¿Puede equivocarse?

Un punto justo fuera del conjunto puede seguir de cerca un ciclo durante un tiempo antes de irse, y marcarse como interior. La tolerancia depende del tamaño del píxel (y es como mucho 10⁻⁶), así que solo les pasa a puntos a una fracción ínfima de píxel del borde. El color de ese píxel era ambiguo de todos modos.

7. Conjuntos sin área: el estimador de distancia

Elige Dendrita, c = i en Dentro / fuera: casi no se dibuja nada, porque casi ningún centro de píxel cae justo sobre el árbol. Necesitamos saber no solo si un píxel se escapa, sino a qué distancia está del conjunto. Si el conjunto pasa a menos de un píxel, lo pintamos.

El truco es llevar, junto a z, su derivada respecto al punto de partida: cuánto estira la órbita un empujón diminuto al principio. Elevar al cuadrado la multiplica por 2z en cada paso. Cerca del conjunto, las órbitas se estiran muchísimo, así que una derivada grande significa "cerca". Milnor muestra que |z|·log|z| / |z′| da la distancia salvo un factor dos; usamos la mitad, el lado seguro.

vec2 dz = vec2(1.0, 0.0);   // derivative of z_n with respect to the starting point
…
  if (dot(dz, dz) < 1e30) dz = 2.0 * cmul(z, dz); else huge = true;   // (z²)' = 2·z·z'
  z = vec2(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y) + uC;
…
// Distance estimate (Milnor): the distance to J is |z|·log|z| / |dz| up to a factor of two,
// so half of it is a safe lower bound. In plane units.
float dist = huge ? 0.0 : 0.5 * r * log(r) / length(dz);
float s = dist / pix;                               // in pixels
float edge = 1.0 - smoothstep(0.0, 1.0, s);         // bright within about a pixel of the set

Cambia a Estimador de distancia y visita la dendrita y el polvo de Cantor. El mapa usa la misma idea, con la derivada respecto a c, y por eso sus filamentos finos siguen visibles con cualquier zoom.

¿De dónde sale la fórmula?

Fuera del Julia lleno hay un "potencial" G, como el potencial eléctrico alrededor de una placa metálica cargada con esa forma: G = lím log|zn| / 2n. Vale cero sobre el conjunto y crece al alejarse. La distancia es más o menos G dividido por lo rápido que cambia G, y ambas cosas salen de zn y su derivada. Milnor señala que es justo el paso que daría el método de Newton para resolver G = 0. Según la Mu-Ency de Munafo, el método se atribuye a Thurston y lo difundió el libro The Beauty of Fractals de Peitgen y Richter.

8. Fluido mientras c se mueve

Al mover el Mandelbrot solo se redibuja cuando te mueves. Aquí, arrastrar c o recorrer el borde cambia todos los píxeles en cada fotograma, así que lo que notas es la velocidad en movimiento. La primera versión usaba media resolución fija al moverse. Es demasiado basta en una GPU rápida y demasiado lenta en una débil. Ahora la resolución en movimiento se adapta: si un fotograma en movimiento tardó menos de 8 ms, el siguiente lleva un 25 % más de píxeles por lado; si tardó más de 24 ms, un 20 % menos.

if (view.moving) {
  // Sharper while moving if frames are cheap, coarser if they are not.
  const f = view.frameMs < MOVE_FAST_MS ? 1.25 : view.frameMs > MOVE_SLOW_MS ? 0.8 : 1;
  moveScale = Math.min(idleScale, Math.max(0.25, moveScale * f));
}
…
view.moved = () => {
  if (!view.moving) { view.moving = true; lastDraw = 0; }
  if (view.scale !== moveScale) setScale(moveScale);
  clearTimeout(settleTimer);
  settleTimer = setTimeout(() => { view.moving = false; setScale(idleScale); view.requestDraw(); }, SETTLE_MS);
  view.requestDraw();
};

El diagnóstico muestra la resolución y los fotogramas por segundo en movimiento. La escena del Mandelbrot gana el mismo comportamiento a través del módulo compartido.

9. El paseo

El borde del cardioide principal tiene una fórmula sencilla: c = eiθ/2 − e2iθ/4. Son los c en los que el punto fijo de z² + c es neutro, ni atractor ni repulsor. Recorrer θ de 0 a 2π lleva a c por todo el borde en un minuto. El paseo solo empieza cuando pulsas el botón.

// The edge of the main cardioid, c(θ) = e^{iθ}/2 − e^{2iθ}/4.
function cardioid(theta) {
  const [a, b] = [Math.cos(theta) / 2, Math.sin(theta) / 2];                // e^{iθ}/2
  const [c2, s2] = [Math.cos(2 * theta) / 4, Math.sin(2 * theta) / 4];      // e^{2iθ}/4
  return [a - c2, b - s2];
}

function walkStep(now) {
  if (!state.walking) return;
  if (walkLast) state.theta += ((now - walkLast) / 1000) * (Math.PI * 2 / WALK_SECONDS);
  walkLast = now;
  const [x, y] = cardioid(state.theta);
  setC({ x, y });
  requestAnimationFrame(walkStep);
}

Por el camino, el Julia se estrangula donde se pegan los bulbos (en ángulos que son fracciones de vuelta) y, para muchos ángulos intermedios, pasa por discos de Siegel como el de los lugares para visitar. Todos esos conjuntos son conexos, porque el borde pertenece al Mandelbrot.

Para seguir

  • Perturbación para los Julia. La regla de la diferencia es más simple que en el Mandelbrot, δ′ = 2·Z·δ + δ², sin Δc. Pero la órbita de referencia empieza en el centro de la vista, no en 0, así que el rebasing necesita una segunda referencia: la órbita de 0.
  • Iteración inversa: aplicar z → ±√(z − c) hacia atrás desde cualquier punto. Cae sobre el propio Julia y es muy rápida, pero deja sin ver las partes interiores (Milnor, Apéndice G).
  • Otras potencias, z → zd + c, dan conjuntos de Julia con simetría de orden d y su propio mapa, el "Multibrot".

SourcesFuentes

Tutorial format inspired by Catlike Coding. Continues the Mandelbrot scene.Formato de tutorial inspirado en Catlike Coding. Continúa la escena del Mandelbrot.