What you will build
We’ll create a LÖVE program that renders the Mandelbrot set on the CPU into a Canvas, and lets you click to smoothly zoom toward a point. The code re-renders the fractal when a zoom completes to keep interactive performance reasonable.
Prerequisites
- Install LÖVE (love2d) from https://love2d.org/ and be able to run a project by dropping a folder onto the love executable or running
love path/to/project. - Basic familiarity with Lua and the LÖVE API (love.load, love.update, love.draw, canvases, images).
Project structure
Create a folder for the project and place a single file named main.lua inside it. You can run it with LÖVE directly.
Full main.lua source
This is the full program. Save it as main.lua inside your project folder.
-- main.lua
local width, height
local canvas
local xmin, xmax, ymin, ymax
local maxIter = 120
local zoomFactor = 0.5
local anim = false
local animSteps = 18
local animStep = 0
local target = {}
local startBounds = {}
function love.load()
love.window.setTitle("Mandelbrot click-to-zoom (LÖVE)")
width, height = 800, 600
love.window.setMode(width, height)
canvas = love.graphics.newCanvas(width, height)
xmin, xmax = -2.5, 1.0
ymin, ymax = -1.2, 1.2
renderMandelbrot()
end
local function screenToComplex(sx, sy, x0, x1, y0, y1)
local cx = x0 + (sx / width) * (x1 - x0)
local cy = y0 + (sy / height) * (y1 - y0)
return cx, cy
end
local function colorForIter(i, maxIter)
if i >= maxIter then
return 0, 0, 0
end
local t = i / maxIter
local r = 0.5 + 0.5 * math.cos(3.0 + 6.2831 * t)
local g = 0.5 + 0.5 * math.cos(1.0 + 6.2831 * t)
local b = 0.5 + 0.5 * math.cos(5.0 + 6.2831 * t)
return r, g, b
end
local function mandelbrotIter(cx, cy, maxIter)
local x, y = 0.0, 0.0
local xx, yy, xy = 0.0, 0.0, 0.0
local i = 0
while i < maxIter and xx + yy <= 4.0 do
xy = x * y
xx = x * x
yy = y * y
x = xx - yy + cx
y = 2.0 * xy + cy
i = i + 1
end
return i
end
function renderMandelbrot()
love.graphics.setCanvas(canvas)
love.graphics.clear(0,0,0,1)
local imagedata = love.image.newImageData(width, height)
for sy = 0, height - 1 do
for sx = 0, width - 1 do
local cx, cy = screenToComplex(sx, sy, xmin, xmax, ymin, ymax)
local i = mandelbrotIter(cx, cy, maxIter)
local r,g,b = colorForIter(i, maxIter)
imagedata:setPixel(sx, sy, r, g, b, 1)
end
end
local image = love.graphics.newImage(imagedata)
love.graphics.draw(image, 0, 0)
love.graphics.setCanvas()
end
function startZoom(sx, sy)
startBounds.xmin, startBounds.xmax = xmin, xmax
startBounds.ymin, startBounds.ymax = ymin, ymax
local cx, cy = screenToComplex(sx, sy, xmin, xmax, ymin, ymax)
local w = (xmax - xmin) * zoomFactor
local h = (ymax - ymin) * zoomFactor
target.xmin = cx - w * 0.5
target.xmax = cx + w * 0.5
target.ymin = cy - h * 0.5
target.ymax = cy + h * 0.5
anim = true
animStep = 0
end
function love.mousepressed(x, y, button)
if button == 1 and not anim then
startZoom(x, y)
end
end
function love.update(dt)
if anim then
animStep = animStep + 1
local t = animStep / animSteps
t = t * t * (3 - 2 * t)
xmin = startBounds.xmin + (target.xmin - startBounds.xmin) * t
xmax = startBounds.xmax + (target.xmax - startBounds.xmax) * t
ymin = startBounds.ymin + (target.ymin - startBounds.ymin) * t
ymax = startBounds.ymax + (target.ymax - startBounds.ymax) * t
if animStep >= animSteps then
anim = false
maxIter = math.min(2000, math.floor(maxIter * 1.15))
renderMandelbrot()
end
end
end
function love.draw()
local ww, wh = love.graphics.getDimensions()
local sx = ww / width
local sy = wh / height
love.graphics.push()
love.graphics.scale(sx, sy)
love.graphics.setColor(1,1,1)
love.graphics.draw(canvas, 0, 0)
love.graphics.pop()
love.graphics.setColor(1,1,1)
love.graphics.print("Click to zoom. Iterations: " .. tostring(maxIter), 10, 10)
end
function love.keypressed(key)
if key == "r" then
xmin, xmax = -2.5, 1.0
ymin, ymax = -1.2, 1.2
maxIter = 120
renderMandelbrot()
elseif key == "escape" then
love.event.quit()
end
end
How the code is organized
- State and setup: variables for viewport bounds, canvas, iterations, zoom/animation parameters are declared at the top and initialized in love.load.
- screenToComplex: converts pixel coordinates to complex plane coordinates using current bounds.
- mandelbrotIter: computes escape iterations for a single complex point (straightforward CPU loop).
- colorForIter: returns an RGB color for a given iteration count; uses cosine palette for smooth-looking bands.
- renderMandelbrot: expensive CPU function that builds an ImageData pixel-by-pixel then converts it to an Image and draws it into the Canvas.
- startZoom / mousepressed: when you click, compute a new target rectangle centered on the clicked complex coordinate and animate toward it.
- love.update: interpolates viewport bounds each frame while animating. When animation finishes, it increases iterations slightly and re-renders the fractal.
- love.draw: draws the cached Canvas (so we only re-render on zoom completion) and the HUD text.
Fractal calculations explained
This section breaks down the math and algorithms behind the Mandelbrot rendering used in the code above: coordinate mapping, the iteration formula, escape checks, and smoothing for nicer colors.
1. Complex plane mapping
Each screen pixel (sx, sy) is mapped to a complex number c = x + yi inside the current viewport bounds xmin, xmax, ymin, ymax. The mapping is linear:
In the code this is performed by screenToComplex. The viewport bounds determine which region of the complex plane you are looking at; zooming changes those bounds to focus on smaller regions.
2. Iteration formula
The Mandelbrot set is defined by iterating the simple quadratic recurrence for each complex point c:
Write z = x + iy. Squaring z gives:
So the real and imaginary parts update as:
In the code we iterate these real arithmetic updates (faster and simpler than using complex numbers directly).
3. Escape check and max iterations
To decide whether c belongs to the Mandelbrot set, we iterate until either the magnitude |z| exceeds an escape radius (commonly 2) or we reach a maximum iteration count maxIter. If |z| > 2 then the sequence diverges and c is outside the set; otherwise we treat it as inside (or undecided if we hit maxIter).
The code uses a loop that increments an iteration counter and checks xx + yy <= 4.0 where xx and yy are x^2 and y^2 to avoid recomputing squares.
4. Coloring: banded vs smooth
Simple coloring uses the integer iteration count: points that escape quickly get one color, points that take longer get another, and points that never escape (reached maxIter) are usually colored black. This produces visible bands.
For smoother gradients, use a normalized iteration value that accounts for how far past the escape threshold the last iteration went. A common smooth formula is:
Here n is the integer iteration where |z| first exceeded the threshold, and |z_n| is the magnitude at that step. The fractional part gives continuous color transitions across bands. Implementing smoothing requires computing a floating-point value for color interpolation instead of only the integer count; the shader path makes this especially easy.
5. Practical notes on numeric stability and performance
- Use squared magnitude checks (x^2 + y^2) rather than sqrt to avoid expensive operations.
- Keep intermediate squared values (xx, yy) when possible to reduce repeated work.
- Increase
maxIteras you zoom in to reveal finer detail; doubling iterations at deep zooms is common but expensive. - For extremely deep zooms, use higher-precision arithmetic (Lua numbers are double-precision; beyond that you need multiprecision libraries or special techniques). Standard double-precision limits how far you can meaningfully zoom before numerical error appears.
6. Pseudocode summary
for each pixel (sx,sy):
cx, cy = map pixel to complex plane
x, y = 0, 0
xx, yy = 0, 0
n = 0
while n < maxIter and xx + yy <= 4:
xy = x * y
xx = x * x
yy = y * y
x = xx - yy + cx
y = 2 * xy + cy
n = n + 1
if n >= maxIter:
color = insideColor
else:
optionally compute smooth value u = n + 1 - log(log(sqrt(xx+yy)))/log(2)
color = palette(u)
Performance notes and tips
- Reduce initial
width/heightor render to a smaller internal canvas and scale up for a faster interactive feel. - Increase
maxItergradually after zooms (the code multiplies it by 1.15) to preserve detail without starting high. - Consider progressive rendering (render rows or tiles over several frames) so the UI stays responsive while the image finishes.
- Best performance: implement the Mandelbrot computation in a GLSL shader (Canvas + Shader) — that gives realtime zoom with high iterations on the GPU.
Suggested improvements
- Add mouse-wheel zoom and drag-to-pan so you can explore more smoothly.
- Right-click to zoom out (inverse zoomFactor), or add a reset button (the key
ralready resets). - Swap CPU loop for a Shader using
love.graphics.newShaderand pass uniforms for bounds and iterations for real-time zooming. - Implement smooth coloring using normalized iteration counts and logarithms (escape smoothing) for prettier gradients.
How to run
- Create a new folder and save the provided code as
main.lua. - Open that folder with LÖVE (drag the folder onto the love executable or run
love path/to/folder). - Click anywhere in the window to zoom in; press
rto reset; pressEscto quit.