Mandelbrot Click-to-Zoom in LÖVE

Screenshot of the actual program (you have to compile to be able to zoom):

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

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

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:

cx = xmin + (sx / width) * (xmax - xmin); cy = ymin + (sy / height) * (ymax - ymin)

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:

z_{0} = 0; z_{n+1} = z_{n}^2 + c

Write z = x + iy. Squaring z gives:

(x + i y)^2 = (x^2 - y^2) + i(2 x y)

So the real and imaginary parts update as:

x_{n+1} = x_n^2 - y_n^2 + c_x; y_{n+1} = 2 x_n y_n + c_y

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).

escape condition: |z|^2 = x^2 + y^2 > 4

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:

\nu = n + 1 - \log(\log|z_n|)/\log 2

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

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

CPU rendering is slow — the provided implementation computes every pixel on the CPU and re-renders the whole image after each zoom. That works fine for moderate window sizes and iteration counts, but becomes slow when you increase resolution or iterations.

Suggested improvements

How to run

  1. Create a new folder and save the provided code as main.lua.
  2. Open that folder with LÖVE (drag the folder onto the love executable or run love path/to/folder).
  3. Click anywhere in the window to zoom in; press r to reset; press Esc to quit.
Next tutorial: Animated plasma effect