Interactively zooming in to the Mandelbrot set on a touchscreen


(Longer video)

I've implemented the Mandelbrot set a few times, but this was still surprisingly delightful.

A basic version of the mandelbrot set is just 20 lines:

function mandelbrot(x0, y0, w, n)
  local zoom = w/Safe_width
  for y = 0, Safe_height do
    local ci = y0 + y*zoom
    for x = 0, Safe_width do
      local cr = x0 + x*zoom
      local niters = mandel_iters(cr, ci, n)
      color(1-niters/n, 1-niters/n, 1-niters/n)
      pt(x, y)
    end end end
function mandel_iters(cr, ci, n)
  local zr, zi = 0, 0
  for i=0,n-1 do
    local nzr = zr*zr - zi*zi + cr
    local nzi = 2*zr*zi + ci
    if nzr*nzr + nzi*nzi > 4 then return i end
    zr, zi = nzr, nzi
  end
  return n
end
mandelbrot(-2.2, -1.5, 3, 256)

Another 20 lines adds color.

A 100 more lines, and we can give it panning and zooming on a multitouch screen. Since it's a little CPU intensive, we'll rerender at a much lower resolution while panning/zooming are in progress.

pan_zoom_coarsen = 10  -- how much to coarsen the picture on pan/zoom. 1 = perfect, larger number = more coarse
-- 16-color Mandelbrot palette
Palette = {
{66, 30, 15},  -- brown 3
{25, 7, 26},  -- dark violet
{9, 1, 47},  -- darkest blue
{4, 4, 73},  -- blue 5
{0, 7, 100},  -- blue 4
{12, 44, 138},  -- blue 3
{24, 82, 177},  -- blue 2
{57, 125, 209},  -- blue 1
{134, 181, 229},  -- blue 0
{211, 236, 248},  -- lightest blue
{241, 233, 191},  -- lightest yellow
{248, 201, 95},  -- light yellow
{255, 170, 0},  -- dirty yellow
{204, 128, 0},  -- brown 0
{153, 87, 0},  -- brown 1
{106, 52, 3},  -- brown 2
}
mandel = {}
work_factor = 1
function mandelbrot(x0, y0, w, W, H, n)
  mandel = {}
  mandel.x0, mandel.y0 = x0, y0
  local zoom = w/W
  for y = 1, H/work_factor do
    local row = {}
    table.insert(mandel, row)
    local ci = y0 + y*work_factor*zoom
    for x = 1, W/work_factor do
      local cr = x0 + x*work_factor*zoom
      table.insert(row, mandel_iters(cr, ci, n))
    end end end
function render(X, Y, W, H)
  color(0.5, 0.5, 0.5)
  rect('line', X, Y, W, H)
  for y = 1, H/work_factor do
    for x = 1, W/work_factor do
      local niters = mandel[y][x]
      local c = Palette[niters%16+1]
      color(c[1]/255, c[2]/255, c[3]/255)
      rect('fill', X+x*work_factor, Y+y*work_factor, work_factor, work_factor)
    end end end
function mandel_iters(cr, ci, n)
  local zr, zi = 0, 0
  for i=0,n-1 do
    local nzr = zr*zr - zi*zi + cr
    local nzi = 2*zr*zi + ci
    if nzr*nzr + nzi*nzi > 4 then return i end
    zr, zi = nzr, nzi
  end
  return n
end
dirty = true
function car.draw()
  if dirty then
    -- map (0, Safe_width) on the viewport at zoom 1.0
    -- to (-2.2, 0.8)
    local w = 3/v.zoom
    local x0 = -2.2 + v.x*3/Safe_width
    local y0 = -1.1 + v.y*3/Safe_width
    mandelbrot(x0, y0, w, Safe_width, Safe_height, 256)
    dirty = false
  end
  render(0, 0, Safe_width, Safe_height)
  color(1,1,1)
  g.print(('zoom: %.2f'):format(v.zoom), 30,60)
  g.print(('x: %f'):format(mandel.x0), 30,85)
  g.print(('y: %f'):format(mandel.y0), 30,110)
end
-- pan/zoom surface
-- v: the viewport
-- in units of screen pixels
if v == nil then
  v ={x=0, y=0, w=Safe_width, h=Safe_height, zoom=1.0}
end
f,s = nil  -- ids of first and second touches
start, curr = {}, {}  -- coords of touches
initzoom = nil
initpos = nil -- for panning
function car.keychord_press(chord, key)
  if chord == 'M--' then
    v.zoom = v.zoom * 0.9
    dirty = true
  elseif chord == 'M-=' then
    v.zoom = v.zoom / 0.9
    dirty = true
  elseif chord == 'M-0' then
    v.zoom = 1.0
    dirty = true
  elseif chord == 'up' then
    v.y = v.y - iscale(Safe_height/10)
    dirty = true
  elseif chord == 'down' then
    v.y = v.y + iscale(Safe_height/10)
    dirty = true
  elseif chord == 'left' then
    v.x = v.x - iscale(Safe_width/10)
    dirty = true
  elseif chord == 'right' then
    v.x = v.x + iscale(Safe_width/10)
    dirty = true
  elseif chord == 'pageup' or chord == 'C-up' then
    v.y = v.y - iscale(Safe_height/2)
    dirty = true
  elseif chord == 'pagedown' or chord == 'C-down' then
    v.y = v.y + iscale(Safe_height/2)
    dirty = true
  elseif chord == 'C-left' then
    v.x = v.x - iscale(Safe_width/2)
    dirty = true
  elseif chord == 'C-right' then
    v.x = v.x + iscale(Safe_width/2)
    dirty = true
  end end
function car.mousepressed(x, y)  -- only if no touchscreen
  initpos = {x=v.x, y=v.y}
  start.mouse = {x=x, y=y}
  dirty = true
  work_factor = pan_zoom_coarsen
end
function car.mousereleased(x, y)
  initpos = nil
  dirty = true
  work_factor = 1
end
function car.update()
  if f then return end  -- touchscreen exists
  if initpos == nil then return end
  local x,y = love.mouse.getPosition()
  v.x = initpos.x + iscale(start.mouse.x - x)
  v.y = initpos.y + iscale(start.mouse.y - y)
  dirty = true
  work_factor = pan_zoom_coarsen
end
function car.touchpressed(id, x,y, ...)
  car.mousepressed = nil  -- disable the mouse the first time the touchscreen triggers
  car.mousereleased = nil
  if f == nil then
    f = id
    initpos = {x=v.x, y=v.y}
  else
    s = id
    initzoom = v.zoom
  end
  start[id] = {x=x, y=y}
  curr[id] = {x=x, y=y}
  dirty = true
  work_factor = pan_zoom_coarsen
end
function car.touchreleased(id, x,y, ...)
  f,s = nil
  start, curr = {}, {}
  initzoom = nil
  initpos = nil
  dirty = true
  work_factor = 1
end
function car.touchmoved(id, x,y, ...)
  if start[id] then
    curr[id] = {x=x, y=y}
    if s then
      local oldzoom = v.zoom
      v.zoom = dist(curr[f], curr[s])/dist(start[f], start[s])*initzoom
      adjust_viewport(oldzoom, v.zoom)
    elseif f then
      v.x = initpos.x + iscale(start[f].x - x)
      v.y = initpos.y + iscale(start[f].y - y)
    end
    dirty = true
    work_factor = pan_zoom_coarsen
  end end
function adjust_viewport(oldzoom, zoom)
  -- ensure centroid of fingers remains in view
  local c = centroid(curr[f], curr[s])
  v.x = v.x + c.x/oldzoom - c.x/zoom
  v.y = v.y + c.y/oldzoom - c.y/zoom
end
function centroid(a, b)
  return{x=(a.x+b.x)/2, y=(a.y+b.y)/2}
end
function vx(sx) return scale(sx-v.x) end
function vy(sy) return scale(sy-v.y) end
function scale(d) return d*v.zoom end
function sx(vx) return v.x + iscale(vx) end
function sy(vy) return v.y + iscale(vy) end
function iscale(d) return d/v.zoom end
function dist(p1, p2)
  return ((p2.x-p1.x)^2 + (p2.y-p1.y)^2) ^ 0.5
end

If you try pasting any of these programs into Lua Carousel, remember to first run the abbreviations on one of the example screens. Or if you've deleted that screen, here are the abbreviations I used in this post:

g = love.graphics
rect = g.rectangle
color = g.setColor

Get Lua Carousel

Leave a comment

Log in with itch.io to leave a comment.