Create your own
Lesson illustration

Efficient Tile Map Rendering with Viewport Culling

Hello! In our last lesson, we designed the architectural blueprint for our game worlds—a multi-layered tile map data structure using JSON. This was a crucial step in defining what our world looks like. Today, we'll focus on the how: bringing that data to life on the screen.

The learning outcome for this lesson is to implement a tile map renderer with a camera viewport that only draws the visible portion of the map. Your experience with rendering and viewports in web development will be helpful here; think of it as building a highly optimized <div> with overflow: scroll for a potentially massive game world, all from scratch on an HTML5 canvas.

We can't just draw every tile of a huge map every frame. A 200x200 tile map has 40,000 tiles. With multiple layers, this number grows, and performance would plummet. The solution is to create a "camera" that follows the action and only renders what's currently in view. This optimization is a cornerstone of 2D game engines.

The Problem: Rendering Large Maps

First, let's get a clear picture of the problem we're solving. Imagine a map that's much larger than our game window. Rendering the parts the player can't even see is a waste of processing power.

Viewport, Camera, and Culling on 2D TileMap - Javascript & Canvas gamedev #3

This video, 'Viewport, Camera, and Culling on 2D TileMap' from Technologies4me, provides an excellent introduction to the challenge. It visually demonstrates why rendering a map larger than the screen is problematic.

At the beginning of the video, please watch the introduction. The key concepts introduced here are the viewport (the visible area), the camera (the mechanism for choosing what to show), and culling (the process of not drawing what's off-screen).

The Solution: A Camera and Viewport Culling

Our solution involves two key ideas:

  1. A Camera Object: This will be a simple JavaScript object that tracks the "window" into our game world. It will have properties like x, y, width, and height. The x and y represent the camera's top-left corner in "world coordinates," while width and height match our canvas dimensions.
  2. Viewport Culling: Before rendering, we'll use the camera's position and size to calculate exactly which tiles are visible. We then instruct our renderer to only loop over and draw this small subset of tiles, ignoring the vast majority that are off-screen.

From World Coordinates to Tile Indices

The core of our culling logic is a mathematical conversion. We need to take the camera's position in the world (measured in pixels) and figure out which rows and columns of our tile grid fall within its view.

The logic is straightforward:

  • To find the first visible column, we take the camera's left edge (camera.x) and divide it by the width of a tile.
  • To find the last visible column, we take the camera's right edge (camera.x + camera.width) and divide by the tile width.
  • We do the same for the top and bottom edges to find the start and end rows.

Let's look at this in pseudo-code:

// Given a camera and map properties
const camera = { x: 500, y: 350, width: 800, height: 600 };
const tileWidth = 32;
const tileHeight = 32;
const mapWidthInTiles = 200;
const mapHeightInTiles = 200;

// Calculate the tile grid range to render
let startCol = Math.floor(camera.x / tileWidth);
let endCol = Math.floor((camera.x + camera.width) / tileWidth);
let startRow = Math.floor(camera.y / tileHeight);
let endRow = Math.floor((camera.y + camera.height) / tileHeight);

// Clamp the values to the map's boundaries to avoid errors
startCol = Math.max(0, startCol);
endCol = Math.min(mapWidthInTiles, endCol + 1); // Add 1 to render partially visible tiles on the edge
startRow = Math.max(0, startRow);
endRow = Math.min(mapHeightInTiles, endRow + 1);

This small block of code is the key to our engine's performance. It reduces the number of tiles we need to consider from tens of thousands to just a few hundred.

The following resource provides a formal explanation for this logic.

How can I calculate all the tiles visible to a camera in 2D?

This Stack Exchange thread, 'How can I calculate all the tiles visible to a camera in 2D?', boils the problem down to its mathematical essence.

Quickly read the two short answers. They confirm our logic: converting world coordinates to tile indices is done by dividing by the tile size. This gives you the tile coordinates at the center, top-left, and bottom-right of the camera's view, defining the bounding box of tiles to render.

Implementing the Optimized Renderer

Now let's put it all together into a rendering function. This function will:

  1. Calculate the visible tile range (startCol, endCol, etc.).
  2. Loop through each layer from our map's JSON data.
  3. For each layer, run a nested for loop over only the visible rows and columns.
  4. For each visible tile, find its tile ID from the layer's data array.
  5. Calculate the tile's source position on the tileset image.
  6. Calculate the tile's destination position on the canvas, subtracting the camera's coordinates to create the scrolling effect.
  7. Draw the tile using ctx.drawImage().

The next video provides a fantastic, step-by-step implementation of this exact process.

Pixel Art Game Development: 2D Camera

The video 'Pixel Art Game Development: 2D Camera' by Franks laboratory shows a very clean implementation of a tile map renderer that uses a camera for culling.

Starting around 40 and a half minutes into the video, please watch the viewport optimization. Pay close attention to these key parts: The calculation of startColumn, endColumn, startRow, and endRow. The modification of the for loops to use these new start/end bounds. The calculation of the destination x and y on the canvas, which includes subtracting the camera's position (offset variables in the video). This is what makes the world appear to move.

Structuring Our Code

Based on the video and our requirements, here is a complete structure for our rendering logic. Let's assume we have a Game class, a Camera class, and have loaded our tileset image and map.json data.

Camera Class:
A simple data object to hold the viewport state.

class Camera {
  constructor(width, height, worldWidth, worldHeight) {
    this.x = 0;
    this.y = 0;
    this.width = width; // Canvas width
    this.height = height; // Canvas height
    this.worldWidth = worldWidth; // Full map width in pixels
    this.worldHeight = worldHeight; // Full map height in pixels
  }

  // A method to move the camera, which we'll use later
  // For now, we can just manually set its x/y to test rendering
  moveTo(x, y) {
    this.x = x;
    this.y = y;

    // Clamp camera to world boundaries
    this.x = Math.max(0, Math.min(this.x, this.worldWidth - this.width));
    this.y = Math.max(0, Math.min(this.y, this.worldHeight - this.height));
  }
}

Renderer Function:
This function lives inside our main game class and is called in every frame of the game loop.

// Inside your main Game class
drawMap(ctx) {
  const { tileWidth, tileHeight, mapWidth, layers } = this.mapData;
  const tileset = this.assetManager.getImage(this.mapData.tilesetSrc);
  const tilesetCols = tileset.width / tileWidth;

  // 1. Calculate visible tile range (culling)
  const startCol = Math.floor(this.camera.x / tileWidth);
  const endCol = Math.min(mapWidth, Math.floor((this.camera.x + this.camera.width) / tileWidth) + 1);
  const startRow = Math.floor(this.camera.y / tileHeight);
  const endRow = Math.min(this.mapData.mapHeight, Math.floor((this.camera.y + this.camera.height) / tileHeight) + 1);

  // 2. Loop through all visible layers
  for (const layer of layers) {
    if (layer.visible === false) continue; // Skip non-visible layers like 'Collision'

    // 3. Loop through visible rows and columns
    for (let r = startRow; r < endRow; r++) {
      for (let c = startCol; c < endCol; c++) {
        const tileId = layer.data[r * mapWidth + c];

        if (tileId === 0) continue; // 0 means empty tile, so skip

        // 4. Calculate source tile position from tileset image
        const tileIndex = tileId - 1; // Adjust for 1-based indexing if needed
        const sx = (tileIndex % tilesetCols) * tileWidth;
        const sy = Math.floor(tileIndex / tilesetCols) * tileHeight;

        // 5. Calculate destination tile position on canvas
        const dx = Math.round(c * tileWidth - this.camera.x);
        const dy = Math.round(r * tileHeight - this.camera.y);

        // 6. Draw the tile
        ctx.drawImage(
          tileset,
          sx, sy, tileWidth, tileHeight, // Source rect
          dx, dy, tileWidth, tileHeight  // Destination rect
        );
      }
    }
  }
}
Test your understanding!

You have a canvas that is 800x600 pixels. Your tiles are 32x32 pixels. The camera is currently positioned at world coordinates x: 320, y: 160.

What are the first and last tile column and row indices that should be rendered? (i.e., what are the values of startCol, endCol, startRow, and endRow before clamping?)

Show answer

Let's calculate:

  • startCol: Math.floor(camera.x / tileWidth) = Math.floor(320 / 32) = 10
  • endCol: Math.floor((camera.x + canvas.width) / tileWidth) + 1 = Math.floor((320 + 800) / 32) + 1 = Math.floor(1120 / 32) + 1 = Math.floor(35) + 1 = 36
  • startRow: Math.floor(camera.y / tileHeight) = Math.floor(160 / 32) = 5
  • endRow: Math.floor((camera.y + canvas.height) / tileHeight) + 1 = Math.floor((160 + 600) / 32) + 1 = Math.floor(760 / 32) + 1 = Math.floor(23.75) + 1 = 23 + 1 = 24

So, your rendering loops would run from column 10 to 35, and row 5 to 23.

Conclusion

Congratulations! You've just implemented one of the most critical systems in a 2D game engine. By combining a camera with viewport culling, you can now render enormous, sprawling worlds with high performance.

Here are the key takeaways from this lesson:

  • Rendering the entire map is inefficient. We must optimize by only drawing what is visible.
  • A Camera defines the viewport. It's a data object tracking the position and size of the visible area in the game world.
  • Culling is key to performance. We calculate the start and end tile indices based on the camera's position to drastically reduce the number of tiles we need to process.
  • World-to-Screen Translation creates movement. By subtracting the camera's world coordinates from each tile's world coordinates, we get their position on the screen, creating the illusion of a scrolling world.

Preview of the next lesson:

Right now, our camera is static. To make our game interactive, we need to move around the world we just rendered. In the next lesson, we will implement grid-based character movement logic controlled by player input. We'll hook up the keyboard, make our character walk on the grid, and prepare to have the camera follow their every move.

Can't find a good explanation? Sign up and we'll make it for you

Sign up