Tutorial:Efficient Tile-based Scrolling

This tutorial introduces the SpriteBatch class for more efficient tile-based scrolling. For a tutorial on basic tile-based scrolling, see Tutorial:Tile-based_Scrolling.

File:Spritebatch screenshot.png
Screenshot of tile-based scrolling with a simple map generator.

Quads and SpriteBatch

The love.graphics.drawq method draws a portion of an image specified by a Quad. If quads are drawn from different parts of a single image, we can make the system more efficient by using a SpriteBatch and not changing the quads every frame.

When creating a SpriteBatch, we must specify the image that we take the quads ("tiles") from and the maximum number of quads that we will be adding. In this case, the maximum number of tiles visible on the screen.

tilesetBatch = love.graphics.newSpriteBatch(tilesetImage, tilesDisplayWidth * tilesDisplayHeight)

Map Initialization

We initialize a map with the following. See the Tile-based Scrolling Tutorial for an explanation.

function love.load()
  mapWidth = 60
  mapHeight = 40
  
  map = {}
  for x=1,mapWidth do
    map[x] = {}
    for y=1,mapHeight do
      map[x][y] = math.random(0,3)
    end
  end

  mapX = 1
  mapY = 1
  tilesDisplayWidth = 26
  tilesDisplayHeight = 20
  
  zoomX = 1
  zoomY = 1
end

Adding a SpriteBatch as Tilemap

Next, we load a tileset, create quads for the tiles we want to use, and make a SpriteBatch object to hold the tiles. As an example, we are going to use a free tileset from http://silveiraneto.net/....

function love.load()
  ... --map init
  
  tilesetImage = love.graphics.newImage( "tileset.png" )
  tilesetImage:setFilter(1,0) -- this "linear filter" removes some artifacts if we were to scale the tiles
  tileSize = 32
  
  -- grass
  tileQuads[0] = love.graphics.newQuad(0 * tileSize, 20 * tileSize, tileSize, tileSize,
    tilesetImage:getWidth(), tilesetImage:getHeight())
  -- kitchen floor tile
  tileQuads[1] = love.graphics.newQuad(2 * tileSize, 0 * tileSize, tileSize, tileSize,
    tilesetImage:getWidth(), tilesetImage:getHeight())
  -- parquet flooring
  tileQuads[2] = love.graphics.newQuad(4 * tileSize, 0 * tileSize, tileSize, tileSize,
    tilesetImage:getWidth(), tilesetImage:getHeight())
  -- middle of red carpet
  tileQuads[3] = love.graphics.newQuad(3 * tileSize, 9 * tileSize, tileSize, tileSize,
    tilesetImage:getWidth(), tilesetImage:getHeight())

  tilesetBatch = love.graphics.newSpriteBatch(tilesetImage, tilesDisplayWidth * tilesDisplayHeight)
end

We only wish to add to the SpriteBatch the tiles that are presently visible. To do this, we make a function that updates the tileset and call it whenever the map focus changes. We also call it once in the initialization. There seems to be a bug in the present version of LÖVE (0.6.0) that forces you to halve the X and Y coordinates of added quads.

function updateTilesetBatch()
  tilesetBatch:clear()
  for x=0, tilesDisplayWidth-1 do
    for y=0, tilesDisplayHeight-1 do
      tilesetBatch:addq(tileQuads[map[x+mapX][y+mapY]], x*tileSize/2, y*tileSize/2)
    end
  end
end

Finally, to draw the SpriteBatch, we just send it to love.graphics.draw.

function love.draw()
  love.graphics.draw(tilesetBatch)
end

Discrete Moving

Moving around the map is done the same way as in the Tile-based Scrolling tutorial; we check if any keys are pressed and update the map accordingly. We must also remember to update the SpriteBatch.

-- central function for moving the map
function moveMap(dx, dy)
  oldMapX = mapX
  oldMapY = mapY
  mapX = math.max(math.min(mapX + dx, mapWidth - tilesDisplayWidth), 1)
  mapY = math.max(math.min(mapY + dy, mapHeight - tilesDisplayHeight), 1)
  -- only update if we actually moved
  if math.floor(mapX) ~= math.floor(oldMapX) or math.floor(mapY) ~= math.floor(oldMapY) then
    updateTilesetBatch()
  end
end

function love.keypressed(key, unicode)
  if key == "up") then
    moveMap(0, -1)
  end
  if key == "down") then
    moveMap(0, 1)
  end
  if key == "left" then
    moveMap(-1, 0)
  end
  if key == "right" then
    moveMap(1, 0)
  end
end

Continuous Movement

We make the movement a bit nicer by allowing mapX and mapY to take on non-integer values. When adding quads to the SpriteBatch, we will only consider the integer part while the drawing will shift the SpriteBatch to handle the fractional part. We replace the love.keypressed callback with a love.update callback and move the map in small steps.

function love.update(dt)
  if love.keyboard.isDown("up")  then
    moveMap(0, -0.2 * tileSize * dt)
  end
  if love.keyboard.isDown("down")  then
    moveMap(0, 0.2 * tileSize * dt)
  end
  if love.keyboard.isDown("left")  then
    moveMap(-0.2 * tileSize * dt, 0)
  end
  if love.keyboard.isDown("right")  then
    moveMap(0.2 * tileSize * dt, 0)
  end
end

We add a floor to the SpriteBatch update.

function updateTilesetBatch()
  tilesetBatch:clear()
  for x=0, tilesDisplayWidth-1 do
    for y=0, tilesDisplayHeight-1 do
      tilesetBatch:addq(tileQuads[map[x+math.floor(mapX)][y+math.floor(mapY)]],
        x*tileSize/2, y*tileSize/2)
    end
  end
end

Finally, we shift the SpriteBatch by the fractional part.

function love.draw()
  love.graphics.draw(tilesetBatch,
    math.floor(-(mapX%1)*tileSize), math.floor(-(mapY%1)*tileSize))
  love.graphics.print("FPS: "..love.timer.getFPS(), 10, 20)
end

Putting It All Together

We have also added zoomX and zoomY variables to this code to allow, e.g., 16x16 tiles to be drawn as 32x32.

local map -- stores tiledata
local mapWidth, mapHeight -- width and height in tiles

local mapX, mapY -- view x,y in tiles. can be a fractional value like 3.25.

local tilesDisplayWidth, tilesDisplayHeight -- number of tiles to show
local zoomX, zoomY

local tilesetImage
local tileSize -- size of tiles in pixels
local tileQuads = {} -- parts of the tileset used for different tiles
local tilesetSprite

function love.load()
  setupMap()
  setupMapView()
  setupTileset()
  love.graphics.setFont(12)
end

function setupMap()
  mapWidth = 60
  mapHeight = 40
  
  map = {}
  for x=1,mapWidth do
    map[x] = {}
    for y=1,mapHeight do
      map[x][y] = math.random(0,3)
    end
  end
end

function setupMapView()
  mapX = 1
  mapY = 1
  tilesDisplayWidth = 26
  tilesDisplayHeight = 20
  
  zoomX = 1
  zoomY = 1
end

function setupTileset()
  tilesetImage = love.graphics.newImage( "tileset.png" )
  tilesetImage:setFilter(1,0)
  tileSize = 32
  tileSize = 32
  
  -- grass
  tileQuads[0] = love.graphics.newQuad(0 * tileSize, 20 * tileSize, tileSize, tileSize,
    tilesetImage:getWidth(), tilesetImage:getHeight())
  -- kitchen floor tile
  tileQuads[1] = love.graphics.newQuad(2 * tileSize, 0 * tileSize, tileSize, tileSize,
    tilesetImage:getWidth(), tilesetImage:getHeight())
  -- parquet flooring
  tileQuads[2] = love.graphics.newQuad(4 * tileSize, 0 * tileSize, tileSize, tileSize,
    tilesetImage:getWidth(), tilesetImage:getHeight())
  -- middle of red carpet
  tileQuads[3] = love.graphics.newQuad(3 * tileSize, 9 * tileSize, tileSize, tileSize,
    tilesetImage:getWidth(), tilesetImage:getHeight())
  
  tilesetBatch = love.graphics.newSpriteBatch(tilesetImage, tilesDisplayWidth * tilesDisplayHeight)
  
  updateTilesetBatch()
end

function updateTilesetBatch()
  tilesetBatch:clear()
  for x=0, tilesDisplayWidth-1 do
    for y=0, tilesDisplayHeight-1 do
      tilesetBatch:addq(tileQuads[map[x+math.floor(mapX)][y+math.floor(mapY)]],
        x*tileSize/2, y*tileSize/2)
    end
  end
end

-- central function for moving the map
function moveMap(dx, dy)
  oldMapX = mapX
  oldMapY = mapY
  mapX = math.max(math.min(mapX + dx, mapWidth - tilesDisplayWidth), 1)
  mapY = math.max(math.min(mapY + dy, mapHeight - tilesDisplayHeight), 1)
  -- only update if we actually moved
  if math.floor(mapX) ~= math.floor(oldMapX) or math.floor(mapY) ~= math.floor(oldMapY) then
    updateTilesetBatch()
  end
end

function love.update(dt)
  if love.keyboard.isDown("up")  then
    moveMap(0, -0.2 * tileSize * dt)
  end
  if love.keyboard.isDown("down")  then
    moveMap(0, 0.2 * tileSize * dt)
  end
  if love.keyboard.isDown("left")  then
    moveMap(-0.2 * tileSize * dt, 0)
  end
  if love.keyboard.isDown("right")  then
    moveMap(0.2 * tileSize * dt, 0)
  end
end

function love.draw()
  love.graphics.draw(tilesetBatch,
    math.floor(-zoomX*(mapX%1)*tileSize), math.floor(-zoomY*(mapY%1)*tileSize),
    0, zoomX, zoomY)
  love.graphics.print("FPS: "..love.timer.getFPS(), 10, 20)
end