Learn Creative Coding (#42) - Fractals: Julia Sets and Variations

Last episode we built the Mandelbrot set from scratch. The core iteration -- z = z*z + c with z starting at zero and c being the pixel coordinate -- the smooth coloring, the cosine palettes, orbit traps, zooming. And right at the end we touched on Julia sets: same iteration formula, but now c is a fixed parameter and z starts at the pixel position.
Today we go deep on Julia sets and then beyond. There's a whole universe of fractal variations hiding behind small changes to the iteration formula. Swap a sign, take an absolute value, raise to a higher power -- each one creates an entirely new family of fractals with its own character. By the end of this episode you'll have a toolkit of fractal formulas that could keep you exploring for months.
Julia sets: the parameter space
Quick recap from last time. The Mandelbrot set iterates z = z*z + c where z starts at (0, 0) and c equals the pixel coordinate. Each pixel asks: "does this value of c cause the iteration to diverge?" The Mandelbrot set is the collection of all c values where it doesn't.
A Julia set flips the setup. You pick ONE specific value for c and keep it constant across the entire image. Instead, each pixel provides the starting value of z. So every pixel asks: "starting from this position, does the iteration with my chosen c diverge?"
The Mandelbrot set is a map of all possible Julia sets. Every point in the complex plane corresponds to a differnt Julia set. Points inside the Mandelbrot set give you connected Julia sets -- one contiguous piece. Points outside give you disconnected Julia sets -- scattered dust called Fatou dust. Points right on the boundary give the most intricate structures.
We already rendered a basic animated Julia in episode 41. Let's now look at the famous classic Julia sets that mathematicians have been studying since Gaston Julia first described them in 1918.
Classic Julia sets: famous c values
Different c values produce wildly different fractals. Here are some of the most visually striking ones:
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 pixel position
vec2 z = uv * 2.5;
// Douady's rabbit: three-fold symmetry
vec2 c = vec2(-0.123, 0.745);
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.02);
if (escaped < maxIter) {
float smoothVal = float(escaped) + 1.0 - log2(log2(length(z)));
float t = smoothVal * 0.025;
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);
}
Douady's rabbit (c = -0.123 + 0.745i) has three-fold rotational symmetry. You can see three main lobes connected at a central point, and each lobe contains smaller copies of the three-lobe structure. Zoom into any junction and you find more rabbits, infinitely deep. It's called the rabbit because the three lobes kinda look like a rabbit head with two ears. Kinda. You have to squint :-)
Now try these other values -- just swap the c line:
// spiral galaxy: c near the Mandelbrot boundary
vec2 c = vec2(-0.7, 0.27015);
// dendrite: tree-like branching structure
vec2 c = vec2(0.0, 1.0);
// San Marco (basilica): repeating archways
vec2 c = vec2(-1.0, 0.0);
// Siegel disk: smooth interior with fractal boundary
vec2 c = vec2(-0.391, -0.587);
// lightning bolt: sharp spiky tendrils
vec2 c = vec2(0.285, 0.01);
Each of these produces a structurally different fractal. The dendrite at c = (0, 1) sits exactly on the Mandelbrot set boundary -- and you can see it. The Julia set is connected but just barely. Every point is a boundary point. There's no interior, no thick regions, just an infinitely branching tree that touches itself everywhere. It's one of the most delicate structures in all of fractal mathematics.
The San Marco Julia at c = (-1, 0) has this cathedral-like repeating structure -- rows of arches inside arches, hence the "basilica" nickname. It sits exactly at the junction between the main cardioid and the period-2 bulb of the Mandelbrot set. If you move c even slightly, the whole structure changes drastically. That's the thing about Julia sets near the Mandelbrot boundary -- they're incredibly sensitive to the exact value of c.
The spiral at c = (-0.7, 0.27015) is my personal favorite. It produces these sweeping spiral arms that wind outward from the center, each arm decorated with smaller spirals. If you zoom in on the tips of the spirals you find... more spirals. Different character from the rabbit's rotational symmetry or the dendrite's branching. It's pure rotation.
Animating c through the Mandelbrot set
The most beautiful Julia set animations come from moving c slowly along a path near the Mandelbrot boundary. Since the Mandelbrot set is the "index" of all Julia sets, tracing a path through it means smoothly morphing from one Julia set to 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);
}
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 z = uv * 2.2;
// trace a cardioid-hugging path
float angle = u_time * 0.1;
float r = 0.7885;
vec2 c = vec2(r * cos(angle), r * 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.01, 0.01, 0.03);
if (escaped < maxIter) {
float smoothVal = float(escaped) + 1.0 - log2(log2(length(z)));
float t = smoothVal * 0.02 + u_time * 0.01;
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)
);
}
gl_FragColor = vec4(color, 1.0);
}
The radius 0.7885 traces a circle that weaves in and out of the Mandelbrot set boundary. When c is inside the set, the Julia set is a connected blob. When c crosses outside, the Julia set shatters into disconnected pieces -- Fatou dust. Right at the boundary, the fractal is maximally complex. The animation cycles between connected, complex, and dusty states in a smooth loop.
Watch carefully as it transitions. The moment the Julia goes from connected to disconnected is dramatic -- the fractal literally falls apart into dust. And when it re-connects, dust coalesces back into a solid shape. It's like watching a universe forming and dissolving.
The + u_time * 0.01 on the palette shifts the colors slowly over time, so the coloring breathes independently of the structural morphing. Double animation -- shape and color evolving on different timescales.
The Burning Ship fractal
OK, time to leave the standard z*z + c family. The Burning Ship fractal makes one tiny change: take the absolute value of the real and imaginary parts of z before squaring.
Normal Mandelbrot: z = z*z + c
Burning Ship: z = (|Re(z)| + i|Im(z)|)^2 + c
In GLSL:
// instead of z = cmul(z, z) + c, do:
z = abs(z); // take absolute value of both components
z = cmul(z, z) + c; // THEN square and add c
That's it. One abs() call. But the result is dramatically different:
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;
// Burning Ship: Mandelbrot-style (c = pixel, z starts at 0)
vec2 c = uv * 3.5 + vec2(-0.4, -0.5);
vec2 z = vec2(0.0);
int maxIter = 256;
int escaped = maxIter;
for (int i = 0; i < 256; i++) {
z = abs(z);
z = cmul(z, z) + c;
if (dot(z, z) > 16.0) {
escaped = i;
break;
}
}
vec3 color = vec3(0.02);
if (escaped < maxIter) {
float smoothVal = float(escaped) + 1.0 - log2(log2(length(z)));
float t = smoothVal * 0.018;
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 Burning Ship has an aggressive, asymmetric geometry. The abs() breaks the smooth rotational symmetry of the Mandelbrot set and replaces it with sharp folds and creases. The main shape looks like... well, a ship on fire. Or a shipwreck. It has a recognizable hull shape with structures that look like masts or flames above it.
The offset + vec2(-0.4, -0.5) centers the ship shape in the viewport. Without it the interesting structure is off to the side. The * 3.5 zoom level gives you a good overview of the whole fractal.
What makes the Burning Ship special visually is the asymmetry. The Mandelbrot set is symmetric across the real axis (the horizontal). The Burning Ship isn't -- the abs() folds negative values to positive before squaring, which treats the upper and lower halves differently. The result is this raw, angular, almost violent-looking fractal. Where the Mandelbrot set has smooth spirals and delicate tendrils, the Burning Ship has sharp spines and jagged edges.
Zoom into the antenna region (the thin line extending to the right from the main body):
// zoom into the antenna region
vec2 c = uv * 0.1 + vec2(-1.755, -0.028);
You'll find mini Burning Ships nested inside, same as mini-brots in the Mandelbrot set. But they're not smooth miniatures -- they're rough, angular, and surrounded by sharp detail rather than smooth spirals.
Burning Ship Julia sets
Just like the Mandelbrot set has corresponding Julia sets, the Burning Ship has its own Julia family. Fix c, start z at the pixel, and apply the absolute value before each squaring:
void main() {
vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;
vec2 z = uv * 2.5;
vec2 c = vec2(-1.5, -0.1); // try different values!
int maxIter = 256;
int escaped = maxIter;
for (int i = 0; i < 256; i++) {
z = abs(z);
z = cmul(z, z) + c;
if (dot(z, z) > 16.0) {
escaped = i;
break;
}
}
// ... same coloring as before
}
Burning Ship Julias are wild. They have none of the elegant rotational symmetry of regular Julia sets. Instead you get these fractured, crystalline structures with sharp edges everywhere. Try c = (-1.5, -0.1) for a spidery explosion of sharp branches. Or c = (-0.5, -0.5) for something that looks like a frozen shatter pattern.
The Tricorn (Mandelbar)
Another single-character variation. Instead of squaring z, we square the complex conjugate of z. The conjugate of a + bi is a - bi -- just flip the sign of the imaginary part.
vec2 cconj(vec2 z) {
return vec2(z.x, -z.y);
}
// in the iteration loop:
z = cmul(cconj(z), cconj(z)) + c;
Or more efficiently -- conjugate once then square:
z.y = -z.y; // conjugate
z = cmul(z, z) + c; // square + c
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 z = vec2(0.0);
int maxIter = 256;
int escaped = maxIter;
for (int i = 0; i < 256; i++) {
z.y = -z.y; // conjugate
z = cmul(z, z) + c; // then square + c
if (dot(z, z) > 16.0) {
escaped = i;
break;
}
}
vec3 color = vec3(0.02);
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(2.0, 1.0, 0.0),
vec3(0.5, 0.2, 0.25)
);
}
gl_FragColor = vec4(color, 1.0);
}
The Tricorn has three-fold symmetry -- hence the name ("tri" + "corn"). Instead of the Mandelbrot's cardioid-with-circle shape, you get a three-pointed figure, like a tricorne hat (the 18th century kind, think pirates). The complex conjugate introduces a reflection into each iteration that breaks the two-fold symmetry of z^2 and replaces it with three-fold symmetry.
The boundary detail of the Tricorn is rougher than the Mandelbrot's. Less smooth spiraling, more angular fractal structure. Mini-tricorns appear along the boundary, just like mini-brots in the Mandelbrot, but they're three-pointed copies rather than two-lobed copies.
Higher-power Mandelbrot sets
What happens if we cube instead of square? z = z^3 + c instead of z = z^2 + c. Or z^4 + c? Or z^5?
Complex multiplication scales up naturally. We already have cmul(a, b) for multiplication. Cubing is just cmul(cmul(z, z), z):
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 * 2.5;
vec2 z = vec2(0.0);
int maxIter = 200;
int escaped = maxIter;
for (int i = 0; i < 200; i++) {
// z^3 + c
z = cmul(cmul(z, z), z) + c;
if (dot(z, z) > 16.0) {
escaped = i;
break;
}
}
vec3 color = vec3(0.02);
if (escaped < maxIter) {
float smoothVal = float(escaped) + 1.0 - log2(log(length(z)) / log(3.0));
float t = smoothVal * 0.025;
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.3, 0.2, 0.2)
);
}
gl_FragColor = vec4(color, 1.0);
}
The z^3 Mandelbrot has three-fold rotational symmetry instead of two-fold. Three main lobes arranged around a central point, with the full fractal boundary structure repeated three times around the circle. z^4 gives four-fold symmetry. z^5 gives five-fold. The pattern continues -- z^n gives n-fold symmetry.
One subtlety: the smooth iteration count formula changes for higher powers. For z^2 we used log2(log2(length(z))). For z^n the general form is log2(log(length(z)) / log(n)). The n in the logarithm accounts for the different growth rate. Without this correction, the smooth coloring has visible artifacts -- faint banding that the wrong formula doesn't eliminate.
Try switching between powers to see the symmetry change:
// z^4: four-fold symmetry
z = cmul(cmul(z, z), cmul(z, z)) + c; // z^2 * z^2
// z^5: five-fold symmetry
vec2 z2 = cmul(z, z);
z = cmul(cmul(z2, z2), z) + c; // z^4 * z
// z^6: six-fold symmetry -- looks like a snowflake
vec2 z2 = cmul(z, z);
z = cmul(cmul(z2, z2), z2) + c; // z^2 * z^2 * z^2
Higher powers produce fractals with more axes of symmetry but thinner, more delicate structure. The boundary filaments become finer and more numerous. At z^8 or higher the structure gets so thin that you need very high iteration counts and zoom levels to see the detail. But the base shapes -- three-fold for cubic, four-fold for quartic, etc -- are instantly recognizable.
Combining techniques: orbit traps on Julia sets
Orbit traps from episode 41 work on Julia sets too. And they produce some of the most visually stunning results when combined with animated Julia parameters:
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 z = uv * 2.5;
// animated c parameter
float angle = u_time * 0.12;
vec2 c = vec2(0.38 * cos(angle) - 0.25, 0.38 * sin(angle));
int maxIter = 200;
int escaped = maxIter;
float minDist = 1e10;
float minAngle = 0.0;
for (int i = 0; i < 200; i++) {
z = cmul(z, z) + c;
// orbit trap: circle at origin, radius 0.5
float d = abs(length(z) - 0.5);
if (d < minDist) {
minDist = d;
minAngle = atan(z.y, z.x);
}
if (dot(z, z) > 16.0) {
escaped = i;
break;
}
}
vec3 color = vec3(0.02, 0.02, 0.04);
if (escaped < maxIter) {
// map trap distance to brightness
float brightness = exp(-minDist * 5.0);
// map trap angle to hue
float hue = minAngle / 6.28318 + 0.5;
// simple HSV-ish coloring
vec3 baseColor = vec3(
0.5 + 0.5 * cos(6.28 * (hue + 0.0)),
0.5 + 0.5 * cos(6.28 * (hue + 0.33)),
0.5 + 0.5 * cos(6.28 * (hue + 0.67))
);
color = baseColor * brightness;
}
gl_FragColor = vec4(color, 1.0);
}
This combines a circle orbit trap with angle-based coloring. The orbit trap distance controls brightness (points whose orbits pass close to the circle of radius 0.5 are bright, others are dim). The angle of the orbit point closest to the trap controls the hue. The result is these flowing, colorful tendrils that trace out the internal dynamics of the fractal.
Because the Julia set parameter c is animated, the whole structure morphs over time. The orbit trap patterns swirl and fold as the fractal reshapes itself. It's like looking at a kaleidoscope made of pure math.
You can swap in different trap shapes. Try a cross trap for grid-like patterns:
// cross orbit trap instead of circle
float dx = abs(z.x);
float dy = abs(z.y);
float d = min(dx, dy);
Or a pair of point traps for something stranger:
// dual point traps
float d1 = length(z - vec2(0.3, 0.0));
float d2 = length(z + vec2(0.3, 0.0));
float d = min(d1, d2);
Distance estimation: glowing boundaries
There's a technique from fractal mathematics that gives us something beyond just escape-speed or orbit-trap coloring. The distance estimator computes (approximately) how far a point is from the actual fractal boundary. You track the derivative of the iteration alongside the value:
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 z = uv * 2.5;
vec2 c = vec2(-0.7, 0.27015);
// derivative starts at 1 + 0i
vec2 dz = vec2(1.0, 0.0);
int maxIter = 256;
int escaped = maxIter;
for (int i = 0; i < 256; i++) {
// chain rule: d/dz(z^2 + c) = 2z * dz/dz_prev
dz = 2.0 * cmul(z, dz);
z = cmul(z, z) + c;
if (dot(z, z) > 1e6) {
escaped = i;
break;
}
}
vec3 color = vec3(0.0);
if (escaped < maxIter) {
// distance estimate: |z| * log(|z|) / |dz|
float zLen = length(z);
float dzLen = length(dz);
float dist = zLen * log(zLen) / dzLen;
// map distance to a glow effect
float glow = clamp(dist * 200.0, 0.0, 1.0);
glow = pow(glow, 0.4);
color = vec3(glow * 0.3, glow * 0.6, glow * 0.9);
}
gl_FragColor = vec4(color, 1.0);
}
The derivative dz follows the chain rule through each iteration. For the Julia set iteration z = z^2 + c, the derivative with respect to the initial z is dz_new = 2 * z * dz_old. We start dz at (1, 0) because the derivative of z with respect to itself is 1.
The distance estimate formula |z| * log(|z|) / |dz| gives the approximate Euclidean distance from the test point to the nearest point on the fractal boundary. Points very close to the boundary have small distance estimates. Points far away have large ones.
The result is a beautiful glow effect around the fractal. The boundary itself is the brightest, with intensity falling off smoothly with distance. It looks like the fractal is emitting light. No hard edges, no banding -- just a continuous radial falloff from the boundary outward.
You can use the distance estimate for other things too. Multiply it into your regular coloring for anti-aliased edges. Use it as a thickness metric for rendering the fractal as thin lines instead of filled regions. Or combine it with iteration count coloring for a hybrid that has smooth boundaries AND colorful detail.
The escape radius for distance estimation needs to be much larger than the usual 4 or 16. I'm using 1e6 (a million). The formula needs the point to have clearly diverged before the distance estimate is accurate. A small escape radius gives noisy, unreliable estimates.
Custom iteration formulas: beyond z^2
Everything we've done so far uses z = f(z) + c where f is some power of z. But f can be anything. Any function that maps complex numbers to complex numbers will produce a fractal. Some are more interesting than others.
// z = z^2 + c/z (rational function -- creates holes)
vec2 zInv = vec2(z.x, -z.y) / dot(z, z); // 1/z = conj(z)/|z|^2
z = cmul(z, z) + cmul(c, zInv);
The 1/z (complex inverse) is computed using the identity 1/z = conj(z) / |z|^2. Dividing by dot(z, z) (which is |z|^2) and conjugating the numerator. The resulting fractal has holes and singularities where z passes near zero, creating voids in the structure surrounded by dense fractal detail.
Another interesting one uses sine:
// approximate complex sine: sin(a+bi) = sin(a)cosh(b) + i*cos(a)sinh(b)
// but for visual purposes, component-wise sin works too:
z = vec2(sin(z.x) * cosh(z.y), cos(z.x) * sinh(z.y)) + c;
Wait, we need cosh and sinh which GLSL doesnt have built-in (in GLES 1.0 anyway). Easy enough:
float cosh_f(float x) { return (exp(x) + exp(-x)) * 0.5; }
float sinh_f(float x) { return (exp(x) - exp(-x)) * 0.5; }
// complex sine
vec2 csin(vec2 z) {
return vec2(sin(z.x) * cosh_f(z.y), cos(z.x) * sinh_f(z.y));
}
// iteration:
z = csin(z) + c;
The sine Mandelbrot/Julia sets are gorgeous. Because sine is periodic, the fractal has a repeating structure along the real axis. But the cosh and sinh on the imaginary part cause exponential growth vertically, so the structure is finite in height but infinite in width. The result looks like a row of connected fractal blobs, each one slightly different from its neighbors.
Try it with c = vec2(1.0, 0.0) for a Mandelbrot-style exploration, or fix c to some value and render the Julia set. Sine Julia sets have a character all their own -- more fluid, more organic than the angular z^2 Julia sets.
Creative exercise: fractal gallery with live switching
Let's build a shader that lets you cycle through different fractal formulas over time, with smooth transitions between them:
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));
}
float cosh_f(float x) { return (exp(x) + exp(-x)) * 0.5; }
float sinh_f(float x) { return (exp(x) - exp(-x)) * 0.5; }
void main() {
vec2 uv = (gl_FragCoord.xy - u_resolution * 0.5) / u_resolution.y;
// cycle through fractal types every 8 seconds
float cycle = mod(u_time, 32.0);
int fractalType = int(cycle / 8.0);
// animate c along Mandelbrot boundary
float angle = u_time * 0.15;
vec2 c = vec2(0.38 * cos(angle) - 0.25, 0.38 * sin(angle));
vec2 z = uv * 2.5;
int maxIter = 256;
int escaped = maxIter;
for (int i = 0; i < 256; i++) {
if (fractalType == 0) {
// standard Julia: z^2 + c
z = cmul(z, z) + c;
} else if (fractalType == 1) {
// Burning Ship Julia
z = abs(z);
z = cmul(z, z) + c;
} else if (fractalType == 2) {
// Tricorn Julia
z.y = -z.y;
z = cmul(z, z) + c;
} else {
// cubic Julia: z^3 + c
z = cmul(cmul(z, z), z) + c;
}
if (dot(z, z) > 16.0) {
escaped = i;
break;
}
}
vec3 color = vec3(0.01, 0.01, 0.03);
if (escaped < maxIter) {
float smoothVal = float(escaped) + 1.0 - log2(log2(length(z)));
float t = smoothVal * 0.02;
// different palette per fractal type
if (fractalType == 0) {
color = palette(t,
vec3(0.5), vec3(0.5), vec3(1.0, 0.7, 0.4), vec3(0.0, 0.15, 0.2));
} else if (fractalType == 1) {
color = palette(t,
vec3(0.5), vec3(0.5), vec3(1.0, 0.4, 0.2), vec3(0.0, 0.05, 0.1));
} else if (fractalType == 2) {
color = palette(t,
vec3(0.5), vec3(0.5), vec3(2.0, 1.0, 0.0), vec3(0.5, 0.2, 0.25));
} else {
color = palette(t,
vec3(0.5), vec3(0.5), vec3(1.0, 1.0, 1.0), vec3(0.0, 0.33, 0.67));
}
}
gl_FragColor = vec4(color, 1.0);
}
Four fractal types, each displayed for 8 seconds, with a unique color palette for each. The c parameter animates continuously throughout, so each fractal type gets different structural configurations as it plays. Standard Julia shows elegant spiral symmetry. Burning Ship Julia shows jagged crystaline forms. Tricorn Julia shows three-fold structure. Cubic Julia shows broader, three-lobed shapes.
The transition between types is abrupt (hard cut) because smoothly morphing between different iteration formulas doesn't really work -- the intermediate states aren't meaningful. You could add a fade-to-black transition if you want something smoother, but the hard cut actually works well here because it emphasizes how different each fractal family is.
Modify this to add your own formulas. Try z = cmul(cmul(z, z), z) + c with z = abs(z) first (cubic Burning Ship). Or add the distance estimator for the glow effect. Or layer orbit traps with different shapes for each fractal type. The iteration loop is where all the variation happens -- everything outside it (coloring, mapping, animation) stays the same.
What these variations reveal
Each fractal variation we explored today -- Julia sets, Burning Ship, Tricorn, higher powers, custom formulas -- tells us something about the relationship between a simple iteration rule and the complex structure it produces. The Mandelbrot set is defined by ONE specific iteration. Change one detail and you get a completely differnt universe of shapes.
But the underlying principle is always the same: iterate, test for escape, color by behavior. The machinery we built in episode 41 -- complex multiplication, smooth iteration count, cosine palettes, orbit traps -- works with ALL of these variations. You swap out the iteration line and everything else just works. That's the power of building modular code. The fractal formula is a parameter to the renderer, not the renderer itself.
There's more to explore. Post-processing effects can add bloom and glow to these fractals (that's coming up soon). And textures can be used to add detail and variation to the coloring. But the fractal math itself -- the iteration formulas, the coloring techniques, the parameter animation -- that's what we covered in these two episodes.
Go experiment. Pick a Julia set c-value you like, add orbit traps, cycle the colors, zoom in. Or invent your own iteration formula. z = cmul(z, z) + c * vec2(cos(float(i) * 0.1), sin(float(i) * 0.1)) where the c itself rotates each iteration. z = cmul(z, z) + c + vec2(0.0, sin(length(z))). Anything that maps vec2 to vec2. Most of them produce something visually interesting. Some of them produce something beautiful. You won't know until you try.
't Komt erop neer...
- Julia sets: same
z = z^2 + citeration as the Mandelbrot, butcis fixed andzstarts at the pixel coordinate. Each c value produces a completely different fractal - Connected vs. disconnected: when
cis inside the Mandelbrot set, the Julia set is one connected piece. Outside, it shatters into Fatou dust. On the boundary, maximum complexity - Famous Julia sets: Douady's rabbit (
c = -0.123, 0.745-- three-fold symmetry), dendrite (c = 0, 1-- pure boundary), San Marco (c = -1, 0-- cathedral arches), spiral (c = -0.7, 0.27-- sweeping arms) - Burning Ship: add
z = abs(z)before squaring. Breaks rotational symmetry, creates aggressive angular geometry. Has its own Julia set family - Tricorn (Mandelbar): conjugate z before squaring (
z.y = -z.y). Produces three-fold symmetry instead of two-fold - Higher powers:
z^3 + cgives three-fold symmetry,z^4gives four-fold,z^ngives n-fold. Smooth coloring formula changes tolog2(log(|z|) / log(n))for power n - Orbit traps work on all variants -- circle traps, cross traps, point traps. Combined with angle-based coloring for flowing, colorful results
- Distance estimation: track the derivative
dzalongsidez. Distance to boundary is approximately|z| * log(|z|) / |dz|. Produces beautiful glow effects - Custom formulas: any function
f(z) + cproduces a fractal. Sine, rational functions, absolute values, higher powers -- each creates its own universe
Sallukes! Thanks for reading.
X