8.4.3 Animated Fractals#
- Duration:
25-30 minutes
- Level:
Intermediate
- Prerequisites:
Module 4.1 (Classical Fractals), Module 8.1 (Animation Fundamentals)
Overview#
Fractals are infinitely complex patterns that repeat at every scale. When you add the dimension of time to fractal visualization, you unlock one of the most mesmerizing experiences in generative art: the fractal zoom. In this exercise, you will create smooth animations that appear to dive infinitely into the Mandelbrot set, revealing layer after layer of intricate detail.
This exercise bridges your knowledge of static fractal generation (Module 4) with animation principles (Module 8), demonstrating how time can serve as a parameter in mathematical visualization. The techniques you learn here apply broadly to any mathematical visualization where exploring parameter space over time creates compelling visual narratives.
Learning Objectives#
By the end of this exercise, you will be able to:
Parameterize fractal generation over time to create smooth animations
Implement exponential zoom interpolation for visually pleasing zoom effects
Convert Mandelbrot iteration counts to color gradients
Generate animated GIFs using frame-based rendering and imageio [ImageIODocs]
Quick Start: See It In Action#
Run this code to create your first animated fractal zoom:
1import numpy as np
2import imageio.v2 as imageio
3
4def mandelbrot_frame(width, height, x_min, x_max, y_min, y_max, max_iter):
5 x = np.linspace(x_min, x_max, width)
6 y = np.linspace(y_min, y_max, height)
7 X, Y = np.meshgrid(x, y)
8 C = X + 1j * Y
9 Z = np.zeros_like(C)
10 iterations = np.zeros(C.shape)
11 for i in range(max_iter):
12 mask = np.abs(Z) <= 2
13 Z[mask] = Z[mask]**2 + C[mask]
14 iterations[mask] = i
15 return (iterations / max_iter * 255).astype(np.uint8)
16
17frames = []
18for frame in range(60):
19 zoom = 500 ** (frame / 59)
20 width = 3.0 / zoom
21 cx, cy = -0.743644, 0.131826
22 img = mandelbrot_frame(400, 400, cx-width/2, cx+width/2, cy-width/2, cy+width/2, 150)
23 frames.append(np.stack([img, img//2, 255-img], axis=-1))
24imageio.mimsave('fractal_zoom.gif', frames, fps=30, loop=0)
A 60-frame animation zooming 500x into the “Seahorse Valley” region of the Mandelbrot set. Notice how each frame reveals new layers of self-similar structure.#
You just created an infinite zoom animation. The magic lies in exponential interpolation: each frame shows a view that is a fixed percentage smaller than the previous one, creating the illusion of constant-speed zooming despite the view window shrinking by a factor of 500.
Core Concepts#
Concept 1: Time as a Fractal Parameter#
Static fractals are beautiful, but they show only a single view of an infinite mathematical object. By making the view window a function of time, we can explore the fractal’s structure dynamically [Peitgen1986].
The key insight is that zoom level should change exponentially, not linearly. If we zoom linearly (adding the same amount each frame), the early frames would feel extremely slow while the later frames would rush past. Exponential zoom creates the perceptually constant speed that makes fractal animations hypnotic.
The Exponential Zoom Formula
Given a starting view width W_0 and a total zoom factor Z over N frames, the view width at frame f is:
For example, with W_0 = 3.0, Z = 500, and N = 60:
Frame 0: Width = 3.0 (full view)
Frame 30: Width = 3.0 / sqrt(500) = 0.134 (22x zoom)
Frame 60: Width = 3.0 / 500 = 0.006 (500x zoom)
def get_view_window(frame, total_frames, center_x, center_y, initial_width, zoom_factor):
# Progress from 0 to 1
progress = frame / (total_frames - 1)
# Exponential interpolation: width shrinks exponentially
current_width = initial_width * (zoom_factor ** (-progress))
# Calculate bounds centered on zoom target
x_min = center_x - current_width / 2
x_max = center_x + current_width / 2
y_min = center_y - current_width / 2
y_max = center_y + current_width / 2
return x_min, x_max, y_min, y_max
The zoom window progression during animation. Each nested rectangle represents the view at a different frame. The exponential spacing ensures visually smooth zooming.#
Concept 2: Fractal Zoom Mechanics#
The Mandelbrot set is defined by iterating the formula z = z^2 + c starting from z = 0, where c is a complex number corresponding to each pixel. Points where the iteration does not escape (|z| <= 2 after many iterations) are inside the set [Mandelbrot1982].
Choosing a Zoom Target
The most visually interesting regions of the Mandelbrot set lie on its boundary, where the set transitions from inside (black) to outside (colored). Famous zoom targets include:
Seahorse Valley (-0.743644, 0.131826): Intricate spiral patterns
Elephant Valley (0.275, 0.0): Trunk-like structures
Mini Mandelbrot (-1.768, 0.0): A tiny copy of the entire set
The coordinates above have been discovered by fractal explorers over decades and represent particularly rich regions of the boundary [Douady1984].
Resolution vs. Iteration Depth Tradeoff
As you zoom deeper, you need more iterations to see fine detail. The relationship is roughly logarithmic: doubling the zoom depth requires only a modest increase in iterations. However, this creates a computational tradeoff:
More iterations = finer boundary detail, slower rendering
Fewer iterations = coarser boundaries, faster rendering
For a 500x zoom, 150-200 iterations typically provides good detail. For deeper zooms (10000x+), you may need 500+ iterations [Devaney1992].
Frame comparison showing zoom progression. Left: Starting view (1x). Center: Midway (22x). Right: Final zoom (500x). Notice how new patterns emerge at each scale.#
Color Mapping
The iteration count for each pixel tells us how quickly that point escaped. Converting this to color creates the characteristic Mandelbrot visualization:
def iterations_to_colors(iterations, max_iter):
# Normalize to 0-1
normalized = iterations / max_iter
# Create RGB array
colors = np.zeros((*iterations.shape, 3), dtype=np.uint8)
# Points inside set (reached max_iter) are black
inside = iterations >= (max_iter - 1)
# Color gradient for escaped points
colors[:, :, 0] = np.where(inside, 0, normalized * 200) # Red
colors[:, :, 1] = np.where(inside, 0, normalized * 100) # Green
colors[:, :, 2] = np.where(inside, 0, 255 - normalized * 200) # Blue
return colors
Did You Know?
The Mandelbrot set has a deep mathematical connection to Julia sets. Each point c in the complex plane corresponds to a unique Julia set generated by the same iteration z = z^2 + c. Points inside the Mandelbrot set produce connected Julia sets, while points outside produce disconnected “dust” Julia sets. This relationship, discovered by Adrien Douady and John Hubbard, explains why the boundary of the Mandelbrot set is where all the visual complexity lives [Douady1984].
Hands-On Exercises#
Now apply what you have learned with three progressively challenging exercises.
Exercise 1: Execute and Explore#
Run the animated_fractal.py script and observe the output. Then answer these reflection questions:
Reflection Questions:
Why does the zoom appear to continue at a constant visual speed despite the view shrinking by 500x?
What happens to the level of detail as we zoom deeper? Why?
The “Seahorse Valley” contains spiral patterns. What mathematical property of the Mandelbrot set creates these spirals?
Why are points inside the Mandelbrot set colored black while the boundary has rich colors?
Answers and Explanation
1. Constant visual speed
The zoom uses exponential interpolation (zoom_factor ** progress), not linear. Each frame shrinks the view by a constant percentage (about 11% per frame for 500x over 60 frames). This matches human perception, which operates on ratios rather than absolute differences.
2. Detail vs. depth
As we zoom deeper, we need more iterations to resolve fine boundary details. The script uses 200 iterations, which provides good detail up to about 500x zoom. Beyond that, boundaries start to look pixelated or noisy because points that would escape with more iterations appear black.
3. Spiral patterns
The spirals emerge from the iteration dynamics near specific points called “Misiurewicz points.” These are points where the iteration eventually becomes periodic. The boundary near these points forms logarithmic spirals, a direct consequence of the complex multiplication in z^2 which rotates and scales the complex plane.
4. Black interior vs. colored boundary
Points inside the set never escape (|z| stays bounded forever), so they reach the maximum iteration count and are colored black. Points outside escape at different rates. The color represents how quickly they escaped, creating the gradient. The boundary is where escape times transition from finite to infinite.
Exercise 2: Modify Parameters#
Experiment with different parameters to create varied animations.
Goal 1: Change the zoom target to explore different regions
Try these alternative coordinates:
# Elephant Valley - trunk-like patterns
CENTER_X = 0.275
CENTER_Y = 0.0
# Mini Mandelbrot - a tiny copy of the whole set
CENTER_X = -1.768
CENTER_Y = 0.0
# Spiral galaxy region
CENTER_X = -0.761574
CENTER_Y = -0.0847596
Hint: Finding interesting coordinates
The most interesting regions are always on the boundary of the set. You can find coordinates by:
Starting with a full view and noting coordinates of interesting areas
Searching online for “Mandelbrot zoom coordinates”
Looking for “filaments” (thin black lines extending from the main set)
Goal 2: Adjust animation speed and duration
# Slower, longer zoom (smoother)
NUM_FRAMES = 120
ZOOM_FACTOR = 1000
# Quick preview
NUM_FRAMES = 30
ZOOM_FACTOR = 100
Goal 3: Modify the color palette
# Warm colors (fire theme)
colors[:, :, 0] = np.where(inside, 0, 255 - normalized * 100) # Red stays high
colors[:, :, 1] = np.where(inside, 0, normalized * 200) # Green increases
colors[:, :, 2] = np.where(inside, 0, normalized * 50) # Blue low
# Cool colors (ocean theme)
colors[:, :, 0] = np.where(inside, 0, normalized * 50)
colors[:, :, 1] = np.where(inside, 0, normalized * 200)
colors[:, :, 2] = np.where(inside, 0, 255 - normalized * 50)
Goal 4: Increase iteration depth for finer detail
MAX_ITERATIONS = 100 # Fast but coarse boundaries
MAX_ITERATIONS = 300 # Detailed but slower
MAX_ITERATIONS = 500 # Very detailed for deep zooms
Solutions
Goal 1: Different coordinates reveal dramatically different structures. The Mini Mandelbrot location shows a perfect miniature copy of the entire set, demonstrating the ultimate self-similarity of fractals.
Goal 2: More frames with higher zoom creates smoother, longer animations. For presentation quality, use 90-120 frames at 30 fps.
Goal 3: The warm palette creates a “molten” look, while cool colors give an underwater feel. Experiment with different channel formulas for unique effects.
Goal 4: Higher iterations are essential for deep zooms. As a rule of thumb, for zoom factor Z, use at least log2(Z) * 50 iterations.
Exercise 3: Re-code from Scratch#
Build your own fractal animation using the animated_fractal_starter.py template.
Part A: Complete the Implementation
The starter code has TODO comments guiding you through implementing:
Coordinate grid creation using
np.linspaceandnp.meshgridMandelbrot iteration with the formula
z = z^2 + cColor mapping from iteration counts to RGB values
Zoom window calculation using exponential interpolation
# Step 1: Create coordinate arrays
real_values = np.linspace(x_min, x_max, width)
imag_values = np.linspace(y_min, y_max, height)
# Step 2: Create 2D grids
real_grid, imag_grid = np.meshgrid(real_values, imag_values)
# Step 3: Complex number array
c_values = real_grid + 1j * imag_grid
# Step 5: Mandelbrot iteration
z_values[still_iterating] = z_values[still_iterating] ** 2 + c_values[still_iterating]
Complete Solution
1def compute_mandelbrot(width, height, x_min, x_max, y_min, y_max, max_iter):
2 real_values = np.linspace(x_min, x_max, width)
3 imag_values = np.linspace(y_min, y_max, height)
4 real_grid, imag_grid = np.meshgrid(real_values, imag_values)
5 c_values = real_grid + 1j * imag_grid
6
7 z_values = np.zeros_like(c_values, dtype=complex)
8 iteration_counts = np.zeros(c_values.shape, dtype=float)
9
10 for iteration in range(max_iter):
11 still_iterating = np.abs(z_values) <= 2
12 z_values[still_iterating] = z_values[still_iterating] ** 2 + c_values[still_iterating]
13 iteration_counts[still_iterating] = iteration
14
15 return iteration_counts
16
17def iterations_to_colors(iteration_counts, max_iter):
18 normalized = iteration_counts / max_iter
19 height, width = iteration_counts.shape
20 colors = np.zeros((height, width, 3), dtype=np.uint8)
21 inside_set = iteration_counts >= (max_iter - 1)
22
23 colors[:, :, 0] = np.where(inside_set, 0, (normalized * 200).astype(np.uint8))
24 colors[:, :, 1] = np.where(inside_set, 0, (normalized * 100).astype(np.uint8))
25 colors[:, :, 2] = np.where(inside_set, 0, (255 - normalized * 200).astype(np.uint8))
26
27 return colors
28
29def calculate_zoom_window(frame_index, total_frames, center_x, center_y,
30 initial_width, zoom_factor):
31 progress = frame_index / (total_frames - 1) if total_frames > 1 else 0
32 current_width = initial_width * (zoom_factor ** (-progress))
33 current_height = current_width
34
35 x_min = center_x - current_width / 2
36 x_max = center_x + current_width / 2
37 y_min = center_y - current_height / 2
38 y_max = center_y + current_height / 2
39
40 return x_min, x_max, y_min, y_max
Part B: Challenge Extension
Create a Julia set morphing animation where the parameter c changes over time:
# Julia set uses fixed c, varying starting z
def julia_frame(width, height, c, max_iter):
x = np.linspace(-2, 2, width)
y = np.linspace(-2, 2, height)
X, Y = np.meshgrid(x, y)
Z = X + 1j * Y # Starting z values (not c!)
iterations = np.zeros(Z.shape)
for i in range(max_iter):
mask = np.abs(Z) <= 2
Z[mask] = Z[mask]**2 + c # c is constant, z varies
iterations[mask] = i
return iterations
# Animate by changing c along the Mandelbrot boundary
for frame in range(60):
angle = frame * 2 * np.pi / 60
c = complex(-0.7 + 0.1 * np.cos(angle), 0.27 + 0.1 * np.sin(angle))
# Generate frame with this c value...
Challenge Solution
1import numpy as np
2import imageio.v2 as imageio
3
4def julia_frame(width, height, c, max_iter):
5 x = np.linspace(-1.5, 1.5, width)
6 y = np.linspace(-1.5, 1.5, height)
7 X, Y = np.meshgrid(x, y)
8 Z = X + 1j * Y
9
10 iterations = np.zeros(Z.shape, dtype=float)
11 for i in range(max_iter):
12 mask = np.abs(Z) <= 2
13 Z[mask] = Z[mask]**2 + c
14 iterations[mask] = i
15
16 normalized = iterations / max_iter
17 colors = np.zeros((*iterations.shape, 3), dtype=np.uint8)
18 inside = iterations >= (max_iter - 1)
19 colors[:, :, 0] = np.where(inside, 0, (normalized * 255).astype(np.uint8))
20 colors[:, :, 1] = np.where(inside, 0, (normalized * 128).astype(np.uint8))
21 colors[:, :, 2] = np.where(inside, 0, (255 - normalized * 200).astype(np.uint8))
22 return colors
23
24# Create morphing animation
25frames = []
26for frame in range(60):
27 angle = frame * 2 * np.pi / 60
28 c = complex(-0.7 + 0.15 * np.cos(angle), 0.27 + 0.15 * np.sin(angle))
29 img = julia_frame(400, 400, c, 150)
30 frames.append(img)
31
32imageio.mimsave('julia_morph.gif', frames, fps=24, loop=0)
33print("Saved: julia_morph.gif")
Summary#
Key Takeaways#
Time parameterizes fractals: By making view coordinates functions of time, static fractals become dynamic explorations [Shiffman2012]
Exponential zoom creates perceptually constant speed, essential for smooth animations
The Mandelbrot iteration
z = z^2 + cdetermines whether points escape, with escape speed creating the color gradientZoom targets matter: the most interesting animations explore the set’s boundary where complexity lives
Iteration depth must increase with zoom level to maintain detail
Julia sets offer an alternative animation approach where the parameter
cchanges instead of the view
Common Pitfalls#
Linear zoom: Creates jarring speed changes. Always use exponential interpolation.
Insufficient iterations: Deep zooms look “muddy” or lose detail. Increase
MAX_ITERATIONSfor deeper zooms.Choosing interior points: Zooming into the black interior is boring. Target the boundary.
Large GIF files: High resolution + many frames = huge files. Balance quality with file size.
Forgetting aspect ratio: Non-square windows distort the fractal if not handled correctly.
Connection to Future Learning#
This exercise establishes foundations for more advanced generative topics:
Module 9.4 Feature Visualization: Neural network feature maps can be animated similar to fractal parameter exploration
Module 12.2 VAE Interpolation: Latent space navigation uses similar interpolation concepts
Module 12.3 Diffusion Models: The denoising process can be visualized as temporal evolution
Next Steps#
Continue your exploration of generative art and animation:
../../8.2_organic_motion/8.2.1_flower_assembly/flower_movie/README to create organic motion patterns
../../../Module_04_fractals_recursion/4.1_classical_fractals/4.1.1_fractal_square/fractal_square/README to review static fractal generation
References#
Mandelbrot, B. B. (1982). The Fractal Geometry of Nature. W. H. Freeman and Company. ISBN: 978-0-7167-1186-5
Peitgen, H.-O., & Richter, P. H. (1986). The Beauty of Fractals: Images of Complex Dynamical Systems. Springer-Verlag. ISBN: 978-3-540-15851-8
Douady, A., & Hubbard, J. H. (1984). Exploring the Mandelbrot set: The Orsay Notes. Publications Mathematiques d’Orsay, 84-02.
Devaney, R. L. (1992). A First Course in Chaotic Dynamical Systems: Theory and Experiment. Westview Press. ISBN: 978-0-201-55406-9
Barnsley, M. F. (1988). Fractals Everywhere. Academic Press. ISBN: 978-0-12-079062-3
Shiffman, D. (2012). The Nature of Code, Chapter 8: Fractals. https://natureofcode.com/book/chapter-8-fractals/
Pearson, M. (2011). Generative Art: A Practical Guide Using Processing. Manning Publications. ISBN: 978-1-935182-62-5
NumPy Developers. (2024). NumPy Reference: Array Creation and Manipulation. https://numpy.org/doc/stable/reference/
imageio Contributors. (2024). imageio Documentation. https://imageio.readthedocs.io/
Sweller, J. (1988). Cognitive load during problem solving: Effects on learning. Cognitive Science, 12(2), 257-285. https://doi.org/10.1207/s15516709cog1202_4