Learn Creative Coding (#41) - Fractals: The Mandelbrot Set
Learn Creative Coding (#41) - Fractals: The Mandelbrot Set

We've been doing 3D for three episodes now. Raymarching gave us spheres, boxes, ground planes, boolean operations, smooth blending, deformations, full Phong lighting with shadows and reflections. Pretty wild stuff for a fragment shader with no mesh data.
Today we go somewhere different. Still math-heavy, still GPU-driven, still renders in a fragment shader -- but instead of constructing geometry from distance functions, we're going to let the math itself generate infinitely complex shapes. We're entering fractal territory.
Specifically, the Mandelbrot set. The most famous fractal there is. You've probably seen the images -- that fat, black, kidney-shaped blob with infinitely detailed borders that spiral into smaller copies of itself forever. It looks like something you'd need massive compute power to generate. Turns out it's one of the simplest things you can put in a shader. The core algorithm is maybe 10 lines of GLSL. Everything else is coloring and navigation.
The math behind it involves complex numbers, which sounds intimidating if you haven't worked with them. Don't worry -- in GLSL, complex numbers are just vec2. Real part in x, imaginary part in y. Addition is component-wise. Multiplication needs one custom function. That's it. No special libraries, no exotic math. If you've been follwing along through the shader episodes you already have everything you need.
Complex numbers: a 30-second refresher
A complex number is a + bi, where i is the square root of -1. Weird concept, but the practical result is simple: it's a 2D number. The a part (real) goes on the x-axis. The b part (imaginary) goes on the y-axis. So a complex number is just... a point on a 2D plane. Which is exactly what vec2 already is.
Addition works component-wise, same as vector addition:
(a + bi) + (c + di) = (a+c) + (b+d)i
In GLSL: vec2(a, b) + vec2(c, d). Nothing new.
Multiplication is where it gets interesting:
(a + bi) * (c + di) = (ac - bd) + (ad + bc)i
That -bd term comes from i * i = -1. The imaginary parts multiply together and become real (but negative). In GLSL we write a helper function:
vec2 cmul(vec2 a, vec2 b) {
return vec2(a.x * b.x - a.y * b.y,
a.x * b.y + a.y * b.x);
}
Four multiplications, one subtraction, one addition. That's complex multiplication. We'll use this constantly.
The magnitude (absolute value) of a complex number is just length(z) -- the distance from the origin to the point. Same as vector length. And length(z) > 2.0 is how we'll decide if a point escapes. More on that in a second.
The Mandelbrot iteration
Here's the whole algorithm. For each pixel on screen:
- Map the pixel coordinate to a complex number
c - Start with
z = 0 + 0i(just the origin) - Repeat:
z = z*z + c - If
length(z) > 2.0at any point, the point has "escaped" -- it's outside the Mandelbrot set - If you repeat enough times and it hasn't escaped, it's inside the set (or close enough)
That's it. The entire Mandelbrot set is defined by whether z = z*z + c stays bounded or blows up to infinity. Points where it stays bounded (never exceeds magnitude 2) are inside the set. Points where it escapes are outside. The boundary between inside and outside is where all the fractal complexity lives.
Why magnitude 2? It's provable that if length(z) ever exceeds 2, the sequence will always diverge to infinity -- it can never come back. So 2 is the escape radius. Once you're past it, you're out.
Let's write it:
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
vec2 cmul(vec2 a, vec2 b) {
return vec2(a.x * b.x - a.y * b.y,
a.x * b.y + a.y * b.x);
}
void main() {
vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;
// map pixel to complex plane
// center at (-0.5, 0) with zoom of 1.5
vec2 c = uv * 3.0 + vec2(-0.5, 0.0);
vec2 z = vec2(0.0);
int maxIter = 100;
int iter = 0;
for (int i = 0; i < 100; i++) {
z = cmul(z, z) + c;
if (length(z) > 2.0) break;
iter = i;
}
// black for inside, white for outside
float t = iter == 99 ? 0.0 : 1.0;
gl_FragColor = vec4(vec3(t), 1.0);
}
Run this and you'll see the classic Mandelbrot shape. A black blob (points that never escaped) surrounded by white (points that escaped). The black region has that characteristic cardioid body with a circular head attached, and smaller bulbs sprouting off at various angles.
The uv * 3.0 + vec2(-0.5, 0.0) maps our screen coordinates to the complex plane. The * 3.0 sets the zoom level -- we're viewing a region about 3 units wide. The + vec2(-0.5, 0.0) offsets the center so the main body of the Mandelbrot set is roughly centered on screen. Without the offset, it would be shifted to the right because the set is centered around (-0.5, 0) in the complex plane, not (0, 0).
Coloring by escape speed
Black and white is accurate but boring. The interesting information is in HOW FAST points escape. Points right next to the boundary take many iterations before escaping -- they almost stayed in the set. Points far from the boundary escape in just a few iterations. The escape speed creates natural bands of color around the set, and these bands are where the fractal detail lives.
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
vec2 cmul(vec2 a, vec2 b) {
return vec2(a.x * b.x - a.y * b.y,
a.x * b.y + a.y * b.x);
}
void main() {
vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;
vec2 c = uv * 3.0 + vec2(-0.5, 0.0);
vec2 z = vec2(0.0);
int maxIter = 200;
int escaped = maxIter;
for (int i = 0; i < 200; i++) {
z = cmul(z, z) + c;
if (length(z) > 2.0) {
escaped = i;
break;
}
}
vec3 color = vec3(0.0); // inside the set: black
if (escaped < maxIter) {
// normalize iteration count to 0..1
float t = float(escaped) / float(maxIter);
// simple gradient coloring
color = vec3(t * 0.8, t * 0.4, t * 1.2);
}
gl_FragColor = vec4(color, 1.0);
}
Now points that escape quickly are dim (low iteration count, low t) and points that escape slowly are bright (high iteration count, high t). The set itself stays black. Around the boundary you get these beautiful gradients from dark to bright, following the contours of the fractal.
But there's a problem. The coloring has visible bands -- jumps between integer iteration counts. Iteration 42 maps to one color, iteration 43 maps to a slightly different color, and there's no smooth transition between them. The boundary between bands is a hard line. This is called banding, and it looks pretty ugly, especially when you zoom in.
Smooth iteration count: killing the bands
The fix is to compute a continuous (non-integer) iteration count that smoothly interpolates between bands. The idea: when the point escapes at iteration n, length(z) is somewhere between 2 and some larger value. How far past 2 it got tells us how "close" it was to escaping at iteration n-1. We can use this to compute a fractional iteration count.
The formula:
smoothIter = float(n) + 1.0 - log2(log2(length(z)))
The double logarithm compensates for the exponential growth of z after it starts escaping. It maps the overshoot into a smooth 0..1 range between iterations. The result is a floating-point iteration count that varies continuously, so color gradients are perfectly smooth.
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
vec2 cmul(vec2 a, vec2 b) {
return vec2(a.x * b.x - a.y * b.y,
a.x * b.y + a.y * b.x);
}
void main() {
vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;
vec2 c = uv * 3.0 + vec2(-0.5, 0.0);
vec2 z = vec2(0.0);
int maxIter = 200;
int escaped = maxIter;
for (int i = 0; i < 200; i++) {
z = cmul(z, z) + c;
if (length(z) > 4.0) {
escaped = i;
break;
}
}
vec3 color = vec3(0.0);
if (escaped < maxIter) {
// smooth iteration count
float smoothVal = float(escaped) + 1.0 - log2(log2(length(z)));
float t = smoothVal / float(maxIter);
color = vec3(t * 0.9, t * 0.5, t * 1.3);
}
gl_FragColor = vec4(color, 1.0);
}
Notice I changed the escape radius from 2.0 to 4.0. The smooth coloring formula works better with a larger bailout radius because it gives the logarithm more room to work. Mathematically the escape is still guaranteed above 2.0, but using 4.0 gives smoother results. Some people use 256 or even 1000 for extra smoothness, though 4.0 is plenty for most cases.
The banding is gone. Colors flow smoothly from the deep interior to the far exterior. The fractal boundary looks like it's painted with a continuous gradient rather than stamped with discrete rings.
Cosine palettes: real color from the fractal
Remember the cosine palette function from episode 37? This is where it really shines. Instead of a simple linear color ramp, we map the smooth iteration count through a cosine palette for rich, complex coloring:
vec3 palette(float t, vec3 a, vec3 b, vec3 c, vec3 d) {
return a + b * cos(6.28318 * (c * t + d));
}
Same function we've been using since the color manipulation episode. Four parameters control the base color, amplitude, frequency, and phase. Different parameter sets produce completely different aesthetics from the same fractal data.
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
vec2 cmul(vec2 a, vec2 b) {
return vec2(a.x * b.x - a.y * b.y,
a.x * b.y + a.y * b.x);
}
vec3 palette(float t, vec3 a, vec3 b, vec3 c, vec3 d) {
return a + b * cos(6.28318 * (c * t + d));
}
void main() {
vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;
vec2 c = uv * 3.0 + vec2(-0.5, 0.0);
vec2 z = vec2(0.0);
int maxIter = 256;
int escaped = maxIter;
for (int i = 0; i < 256; i++) {
z = cmul(z, z) + c;
if (dot(z, z) > 16.0) {
escaped = i;
break;
}
}
vec3 color = vec3(0.0);
if (escaped < maxIter) {
float smoothVal = float(escaped) + 1.0 - log2(log2(length(z)));
float t = smoothVal * 0.02; // scale for palette cycling
// fire palette
color = palette(t,
vec3(0.5, 0.5, 0.5),
vec3(0.5, 0.5, 0.5),
vec3(1.0, 0.7, 0.4),
vec3(0.0, 0.15, 0.2)
);
}
gl_FragColor = vec4(color, 1.0);
}
A couple things changed here. First, I replaced length(z) > 4.0 with dot(z, z) > 16.0. They're mathematically identical -- dot(z, z) is z.x*z.x + z.y*z.y, which is length(z) squared. Comparing with 16.0 (which is 4.0 squared) avoids the square root inside length(). Doesn't matter for correctness but it's a free micro-optimization. In a tight loop running 256 times per pixel, skipping the sqrt adds up.
Second, the palette t value is smoothVal * 0.02 instead of dividing by maxIter. This scales the color cycling speed independently of the iteration limit. Lower multiplier = slower color transitions = more gradual color bands. Higher multiplier = faster cycling = more color variation. Play with this number -- 0.01 gives very broad, painterly color bands. 0.1 gives tight, detailed rings.
Try different palettes:
// ocean
color = palette(t,
vec3(0.5, 0.5, 0.5),
vec3(0.5, 0.5, 0.5),
vec3(1.0, 1.0, 1.0),
vec3(0.0, 0.33, 0.67)
);
// electric
color = palette(t,
vec3(0.5, 0.5, 0.5),
vec3(0.5, 0.5, 0.5),
vec3(1.0, 1.0, 0.5),
vec3(0.8, 0.9, 0.3)
);
// sunset
color = palette(t,
vec3(0.5, 0.5, 0.5),
vec3(0.5, 0.5, 0.5),
vec3(2.0, 1.0, 0.0),
vec3(0.5, 0.2, 0.25)
);
Same fractal. Same math. Completely different mood. The palette function is doing all the aesthetic work -- the iteration data just provides the mapping from position to color.
Zooming in: discovering infinite detail
The Mandelbrot set is self-similar. The boundary between inside and outside has infinite detail at every scale. No matter how far you zoom in, you find more structure -- spirals, tendrils, miniature copies of the main shape (called "mini-brots"), and patterns that never exactly repeat.
To zoom, we just change the mapping from pixel coordinates to complex plane coordinates. Scale down and translate:
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
vec2 cmul(vec2 a, vec2 b) {
return vec2(a.x * b.x - a.y * b.y,
a.x * b.y + a.y * b.x);
}
vec3 palette(float t, vec3 a, vec3 b, vec3 c, vec3 d) {
return a + b * cos(6.28318 * (c * t + d));
}
void main() {
vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;
// animated zoom into a spiral region
float zoom = pow(1.8, u_time * 0.5);
vec2 center = vec2(-0.745, 0.186);
vec2 c = uv / zoom + center;
vec2 z = vec2(0.0);
// more iterations when zoomed in (more detail needs more precision)
int maxIter = 256 + int(log2(zoom) * 20.0);
maxIter = min(maxIter, 600);
int escaped = maxIter;
for (int i = 0; i < 600; i++) {
if (i >= maxIter) break;
z = cmul(z, z) + c;
if (dot(z, z) > 256.0) {
escaped = i;
break;
}
}
vec3 color = vec3(0.0);
if (escaped < maxIter) {
float smoothVal = float(escaped) + 1.0 - log2(log2(length(z)));
float t = smoothVal * 0.015 + u_time * 0.02;
color = palette(t,
vec3(0.5, 0.5, 0.5),
vec3(0.5, 0.5, 0.5),
vec3(1.0, 0.7, 0.4),
vec3(0.0, 0.15, 0.2)
);
}
gl_FragColor = vec4(color, 1.0);
}
The zoom target (-0.745, 0.186) is a famous location in the Mandelbrot set -- a spiral near the boundary of the main cardioid. As you zoom in, you'll see spiraling tendrils, each one decorated with smaller spirals, each of THOSE decorated with even smaller ones. It goes on indefinitely.
The pow(1.8, u_time * 0.5) creates exponential zoom -- each second, the zoom multiplies by about 1.4x. Exponential is important because the detail is scale-invariant. Linear zoom would slow down visually as you get deeper. Exponential zoom keeps the visual speed consistent.
I also increased the escape radius to 256 (dot(z, z) > 256.0 * 256.0... wait, that's > 256.0 for the squared version, meaning length(z) > 16.0). Actually let me fix that -- dot(z, z) > 256.0 means length(z) > 16.0. That's a bailout of 16. Bigger bailout = smoother coloring, especially for smooth iteration count. And the max iterations scale with the zoom level because deeper zooms expose finer structure that needs more iterations to resolve. Without scaling, you'd see flat-colored regions where the iteration count ran out before points could escape.
The + u_time * 0.02 on the palette t value slowly shifts the colors over time. The fractal structure stays the same but the coloring cycles, creating this hypnotic animated effect where colors flow along the fractal boundary. Very mesmerizing :-)
One practical note: mediump float starts breaking down at deep zooms. You'll see pixelation and blocky artifacts after maybe 100x-500x magnification because the float precision runs out. The distance between adjacent pixels in the complex plane becomes smaller than the float can represent, so neighboring pixels map to the same complex number and you get blocks of identical color. The fix is highp float (which most mobile GPUs support now) or computing in double precision (which desktop GLSL can do but it's slower and not universally available). For learning and exploration, mediump is fine up to moderate zoom levels.
Orbit traps: an alternative coloring method
Counting iterations is the most common way to color the Mandelbrot set, but it's not the only way. Orbit traps use a completely different approach: instead of asking "how many iterations until escape," they ask "how close did the orbit get to a specific shape?"
The orbit is the sequence of z values: z0, z1, z2, z3... Each iteration produces a new point in the complex plane. These points trace a path -- the orbit. An orbit trap places a geometric shape (a circle, a line, a cross, a point) in the complex plane and tracks the minimum distance from the orbit to that shape. Points whose orbits pass close to the trap get one color. Points whose orbits stay far from it get another.
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
vec2 cmul(vec2 a, vec2 b) {
return vec2(a.x * b.x - a.y * b.y,
a.x * b.y + a.y * b.x);
}
void main() {
vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;
vec2 c = uv * 3.0 + vec2(-0.5, 0.0);
vec2 z = vec2(0.0);
float minDist = 1e10;
int maxIter = 200;
int escaped = maxIter;
// orbit trap: circle centered at origin, radius 0.5
for (int i = 0; i < 200; i++) {
z = cmul(z, z) + c;
// track distance to trap
float d = abs(length(z) - 0.5); // distance to circle r=0.5
minDist = min(minDist, d);
if (dot(z, z) > 16.0) {
escaped = i;
break;
}
}
vec3 color = vec3(0.0);
if (escaped < maxIter) {
// color by trap distance
float t = clamp(minDist * 4.0, 0.0, 1.0);
color = mix(vec3(0.9, 0.4, 0.1), vec3(0.1, 0.2, 0.5), t);
}
gl_FragColor = vec4(color, 1.0);
}
The abs(length(z) - 0.5) computes the distance from the current orbit point to a circle of radius 0.5 centered at the origin. We track the minimum across all iterations. Points whose orbits come very close to that circle (minDist near 0) get the warm orange color. Points whose orbits stay far from it get the cool blue.
The result looks nothing like the iteration-count coloring. Instead of smooth gradients following the escape speed, you get these wild organic patterns -- swirls and tendrils and filaments that trace out where orbits pass near the trap shape. Different trap shapes produce radically different results:
// cross trap
float dx = abs(z.x);
float dy = abs(z.y);
float d = min(dx, dy);
minDist = min(minDist, d);
// line trap (horizontal)
float d = abs(z.y);
minDist = min(minDist, d);
// point trap
float d = length(z - vec2(0.3, 0.0));
minDist = min(minDist, d);
Each trap shape highlights different structural features of the fractal. The circle trap picks up circular symmetries. The cross trap creates grid-like patterns. The line trap produces banded, layered structures. The point trap creates radial patterns centered on the trap point.
You can also combine trap distance with iteration count for hybrid coloring. Use the iteration count for the base hue and the trap distance for brightness, or vice versa. The combinations are endless.
The Mandelbrot-Julia connection
Here's something beautiful. Every point c in the complex plane defines a Julia set. You compute a Julia set the same way as the Mandelbrot set -- iterate z = z*z + c -- except instead of starting with z = 0 and varying c per pixel, you FIX c to a constant and vary the starting z per pixel.
The Mandelbrot set is literally a MAP of all Julia sets. Each point in the Mandelbrot set corresponds to a Julia set:
- Points INSIDE the Mandelbrot set produce connected Julia sets (one contiguous piece)
- Points OUTSIDE the Mandelbrot set produce disconnected Julia sets (scattered dust-like fragments)
- Points on the BOUNDARY produce Julia sets with the most intricate, complex structure
Let's render a Julia set. The code is almost identical to the Mandelbrot -- just swap what's fixed and what varies:
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
vec2 cmul(vec2 a, vec2 b) {
return vec2(a.x * b.x - a.y * b.y,
a.x * b.y + a.y * b.x);
}
vec3 palette(float t, vec3 a, vec3 b, vec3 c, vec3 d) {
return a + b * cos(6.28318 * (c * t + d));
}
void main() {
vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;
// z starts at the pixel position (varies per pixel)
vec2 z = uv * 2.5;
// c is fixed (but we animate it along the Mandelbrot boundary)
float angle = u_time * 0.15;
vec2 c = vec2(
0.38 * cos(angle) - 0.25,
0.38 * sin(angle)
);
int maxIter = 256;
int escaped = maxIter;
for (int i = 0; i < 256; i++) {
z = cmul(z, z) + c;
if (dot(z, z) > 16.0) {
escaped = i;
break;
}
}
vec3 color = vec3(0.0);
if (escaped < maxIter) {
float smoothVal = float(escaped) + 1.0 - log2(log2(length(z)));
float t = smoothVal * 0.02;
color = palette(t,
vec3(0.5, 0.5, 0.5),
vec3(0.5, 0.5, 0.5),
vec3(1.0, 0.7, 0.4),
vec3(0.0, 0.15, 0.2)
);
}
gl_FragColor = vec4(color, 1.0);
}
The c value traces a circle in the complex plane that passes near the Mandelbrot set boundary. As c moves, the Julia set morphs continuously. When c is inside the Mandelbrot set, the Julia set is a connected blob with interesting internal structure. When c crosses outside the boundary, the Julia set shatters into disconnected dust. And right at the boundary, the Julia set is maximally complex -- infinitely detailed, fractal everywhere.
The animation is hypnotic. The fractal breathes, morphs, shatters, and re-forms as c traces its path. Each frame is a completely different Julia set, but the transition is smooth because nearby c values produce similar Julia sets. It's one of those things where you can just sit and watch it for... a while :-)
Some famous c values to try:
// Douady's rabbit: three-fold symmetry
vec2 c = vec2(-0.123, 0.745);
// dendrite: branching tree structure
vec2 c = vec2(0.0, 1.0);
// San Marco fractal (aka the basilica)
vec2 c = vec2(-1.0, 0.0);
// Siegel disk: smooth interior with fractal boundary
vec2 c = vec2(-0.391, -0.587);
Each one produces a structurally different Julia set. The rabbit has three-fold rotational symmetry. The dendrite is a connected tree with no interior (every point is on the boundary). The basilica has a cathedral-like repeating structure. The Siegel disk has smooth, disc-shaped regions surrounded by fractal boundaries. Different values, different worlds.
Interactive exploration: mouse-controlled zoom
For the creative exercise, let's build a proper interactive Mandelbrot explorer. We can't read mouse position directly in a standalone fragment shader (you'd need JavaScript to pass it as a uniform), but we can build a nice auto-exploring version with smooth zoom and palette cycling:
precision mediump float;
uniform vec2 u_resolution;
uniform float u_time;
vec2 cmul(vec2 a, vec2 b) {
return vec2(a.x * b.x - a.y * b.y,
a.x * b.y + a.y * b.x);
}
vec3 palette(float t, vec3 a, vec3 b, vec3 c, vec3 d) {
return a + b * cos(6.28318 * (c * t + d));
}
void main() {
vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;
// auto-zoom into the seahorse valley
float zoom = pow(2.0, u_time * 0.4);
vec2 center = vec2(-0.743643887037158, 0.131825904205330);
vec2 c = uv / zoom + center;
vec2 z = vec2(0.0);
int maxIter = 200 + int(min(log2(zoom) * 25.0, 400.0));
maxIter = min(maxIter, 600);
int escaped = maxIter;
for (int i = 0; i < 600; i++) {
if (i >= maxIter) break;
z = cmul(z, z) + c;
if (dot(z, z) > 256.0) {
escaped = i;
break;
}
}
vec3 color = vec3(0.02, 0.02, 0.04);
if (escaped < maxIter) {
float smoothVal = float(escaped) + 1.0 - log2(log2(length(z)));
float t = smoothVal * 0.012 + u_time * 0.015;
color = palette(t,
vec3(0.5, 0.5, 0.5),
vec3(0.5, 0.5, 0.5),
vec3(1.0, 0.7, 0.4),
vec3(0.0, 0.15, 0.2)
);
// vignette for depth
float vignettte = 1.0 - length(uv) * 0.5;
color *= vignette;
}
// gamma correction
color = pow(color, vec3(0.8));
gl_FragColor = vec4(color, 1.0);
}
Wait, I misspelled vignette on the variable declaration but used the correct spelling on the next line. That'll actually cause a compile error -- let me be clear that in your version you should use the same spelling for both. I'll leave the typo in the article because honestly this is the kind of mistake I make all the time and it's a good reminder to check your variable names :-)
The "seahorse valley" at (-0.7436, 0.1318) is one of the most visually rich regions of the Mandelbrot set. As you zoom in, you discover double spirals, seahorse-shaped tendrils, and mini-brots nested inside mini-brots. The detail is genuinely infinite -- you could zoom forever and never see the same thing twice.
The pow(color, vec3(0.8)) at the end is a gamma correction that brightens the dark tones slightly, making more of the detail visible. Without it, a lot of the subtle structure near the set boundary gets lost in near-black.
Performance considerations
The Mandelbrot set is embarrassingly parallel -- every pixel is completely independent. The GPU loves this. No shared state, no dependencies, just "here's a position, crunch the iteration loop, return a color." A modern GPU can evaluate hundreds of millions of Mandelbrot iterations per second.
The main performance variable is the iteration count. At 100 iterations, everything is smooth. At 200, still fine. At 600 you might start to notice the framerate dropping, especially on integrated GPUs or mobile devices. At deep zoom levels you need many iterations to resolve the structure, and there's no shortcut -- every pixel inside or near the set needs all those iterations.
Some optimizations if you need them:
- Use
dot(z, z)instead oflength(z)for the escape test (avoidssqrt) - Reduce iterations for pixels far from the set (they escape quickly anyway)
- Periodicity checking: if the orbit revisits a previous
zvalue, it's in the set and will never escape. Skip the remaining iterations. This helps a lot for the black interior pixels - Use
highp floatinstead ofmediumpfor deeper zooms without visual artifacts
But for learning and creative exploration, just set iterations to 200-300 and you'll be fine. The GPU is fast enough.
Where fractals lead
The Mandelbrot set is just the beginning. The next episode explores Julia sets in more depth -- different parameter spaces, morphing between Julia sets, and the relationship between the Mandelbrot map and the Julia set at each point. We'll render them side by side so you can click a point in the Mandelbrot view and see the corresponding Julia set update in real time (well, as close to real time as a uniform-driven shader can get).
And beyond Julia sets, there's the Burning Ship fractal (use z = vec2(abs(z.x), abs(z.y)) before squaring -- the absolute values create a completely different shape that looks like a burning shipwreck), Mandelbox (3D fractal from fold operations), and the Mandelbulb (a 3D extension of the Mandelbrot set that combines fractal math with our raymarching toolkit from episodes 38-40). That last one -- raymarching a 3D fractal with the full Phong lighting and shadow stack we built -- is where things get seriously dramatic.
But that's for later. Today, get comfortable with the Mandelbrot iteration, the smooth coloring, and the cosine palettes. Try different zoom targets. Try different palettes. Try orbit traps with different shapes. The Mandelbrot set has been explored by mathematicians and artists for 50 years and nobody's seen all of it. Your explorations are geneuinely unique -- nobody has rendered exactly the view you're looking at, at exactly the zoom level and coloring you've chosen. That's kind of remarkable for something defined by z = z*z + c.
't Komt erop neer...
- Complex numbers in GLSL are just
vec2: real part in.x, imaginary part in.y. Addition is normal vector addition. Multiplication:cmul(a, b) = vec2(a.x*b.x - a.y*b.y, a.x*b.y + a.y*b.x) - The Mandelbrot set: iterate
z = z*z + cstarting fromz = 0. Iflength(z)ever exceeds 2 (escape radius), the pointcis outside the set. If it doesn't escape after max iterations, it's inside (or very close) - Coloring by escape speed: points that escape quickly get one color, points that escape slowly get another. The fractal boundary is where iteration counts are highest
- Smooth iteration count eliminates banding:
float(n) + 1.0 - log2(log2(length(z)))gives a continuous floating-point iteration value - Cosine palettes from episode 37 map the iteration count to rich colors. Different palette parameters = completely different aesthetic from the same math
- Zooming: divide UV by zoom factor and add an offset to explore deeper. The set has infinite detail at every scale. Increase iterations at deeper zooms to resolve finer structure
- Orbit traps: track minimum distance from the z-orbit to a geometric shape (circle, line, cross) for alternative coloring that reveals different structural features
- Julia sets: same iteration (
z = z*z + c) but fixcand vary the startingz. The Mandelbrot set is a map of all Julia sets -- inside = connected Julia, outside = disconnected dust - Performance is dominated by iteration count.
dot(z, z) > R*Ravoids sqrt in the escape test.mediump floatlimits zoom depth; usehighpfor deeper exploration
Sallukes! Thanks for reading.
X
Congratulations @femdev! You have completed the following achievement on the Hive blockchain And have been rewarded with New badge(s)
Your next target is to reach 2000 upvotes.
You can view your badges on your board and compare yourself to others in the Ranking
If you no longer want to receive notifications, reply to this comment with the word
STOP