Viewport and Culling on a Tile Map

Viewport and Culling methods

What we really need at this stage is a bigger map. The problem is, given our current Canvas size of 400x400 pixels, and our tile dimensions of 40x40 pixels, we can only show a grid 10 tiles wide by 10 tiles high. We'll now create a method for keeping track of the area of the screen that is currently visible.

View example

This has an added bonus; culling. This means we only draw the part of the map that can fit on the canvas. Not trying to draw things that are off the Canvas helps in performance - this is true in general with game development, and is very useful to learn for rendering your maps.

Let's start by changing our gameMap array, to accomodate 4 times as many tiles; our new map will be 20x20 tiles!


var gameMap = [
	0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
	0, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 0,
	0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 1, 1, 1, 0, 1, 0, 1, 0, 0, 0,
	0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 0, 1, 1, 1, 1, 1, 0,
	0, 1, 0, 1, 0, 0, 0, 1, 1, 0, 1, 1, 1, 0, 1, 0, 1, 1, 1, 0,
	0, 1, 0, 1, 0, 1, 0, 0, 1, 0, 1, 1, 1, 1, 1, 0, 1, 1, 1, 0,
	0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1, 0, 0,
	0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 0, 1, 1, 1, 0,
	0, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 0,
	0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 1, 1, 1, 1, 1, 1, 0, 1, 1, 0,
	0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 0,
	0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 0,
	0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0,
	0, 1, 0, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0,
	0, 1, 0, 1, 0, 0, 0, 1, 0, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 0,
	0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0,
	0, 1, 0, 1, 1, 1, 0, 1, 0, 1, 1, 1, 1, 0, 1, 1, 1, 0, 1, 0,
	0, 1, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 1, 0, 1, 1, 1, 0, 1, 0,
	0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0,
	0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
];

We'll also change our mapW and mapH variables to reflect our new map dimensions:


var mapW = 20, mapH = 20;

Viewport Object

We'll now create our viewport object which will keep track of the following information: the Canvas width and height (screen), the tile coordinates of the top-left area of the map that is visible (startTile) and the bottom-right tile coordinates of the visible map area (endTile), and the offset, in pixels, that map tiles and objects should be moved by when drawing relative to their normal position.


var viewport = {
	screen		: [0,0],
	startTile	: [0,0],
	endTile		: [0,0],
	offset		: [0,0],

Our viewport object will have an update method which takes 2 arguments; the x and y position that we want to be the centre of our visible area. We'll begin this function by setting the x and y drawing offsets, which are just calculated from half the Canvas width or height (stored in screen), which would be the natural 0,0 point on the map, minus the offsets that are passed to the method to specify the viewport centre (px and py):


	update		: function(px, py) {
		this.offset[0] = Math.floor((this.screen[0]/2) - px);
		this.offset[1] = Math.floor((this.screen[1]/2) - py);

These values are rounded down to the nearest whole number with Math.floor - this helps prevent tearing when drawing using these values.

Now we find the coordinates of the tile (which does not need to be in map bounds, as it is used just for reference) on which the viewport centre that was specified would fall. We do this by rounding down the result of the px value divided by the width of a tile, and the same with the py value and tile height. We'll be using this as a reference to calculate the first and last tiles of the visible area.


		var tile = [ Math.floor(px/tileW), Math.floor(py/tileH) ];

We can now calculate the position of the first tile on the x axis by calculting the maximum number of tiles that can fit in half of the screen width, and taking that number away from the centre tile. We also remove an additional 1 to allow for tiles that are not completely on the screen, but only partially. The same method is used with the relevant figures for the y axis.

[first tile X] = [centre tile X] - 1 - Round Up(([Canvas width] / 2) / [tile width]);

		this.startTile[0] = tile[0] - 1 - Math.ceil((this.screen[0]/2) / tileW);
		this.startTile[1] = tile[1] - 1 - Math.ceil((this.screen[1]/2) / tileH);

Afterwards we do a quick check to ensure the x and y coordinates aren't less then 0, as this would be outside of the maps bounds. If they are we just set them to 0 - no point in trying to draw things that aren't there (and doing so would mean trying to access array indexes that don't exist).


		if(this.startTile[0] < 0) { this.startTile[0] = 0; }
		if(this.startTile[1] < 0) { this.startTile[1] = 0; }

Our endTile is calculated in nearby the same way, except now we're adding the values to our centre tile. We also do a bounds check afterwards, but this time to check we're not trying to draw beyond the right or bottom edges of the map, so we check the coordinates are less than the map width and height. Remember, Arrays in Javascript (and many other languages) as 0 based, so our last x column is mapW-1, and our last map row is mapH-1.


		this.endTile[0] = tile[0] + 1 + Math.ceil((this.screen[0]/2) / tileW);
		this.endTile[1] = tile[1] + 1 + Math.ceil((this.screen[1]/2) / tileH);

		if(this.endTile[0] >= mapW) { this.endTile[0] = mapW; }
		if(this.endTile[1] >= mapH) { this.endTile[1] = mapH; }

We can now close the update method, and the viewport objects itself.


	}
};

More to do onload

We also need to extend the window onload function - we're just dropping in a small piece of code that checks the Canvas dimensions and stores it in the viewport objects screen property:


	viewport.screen = [document.getElementById('game').width,
		document.getElementById('game').height];

A little change to drawGame

In our drawGame function we're going to update our viewport after any movement has been processed. Typically, I'd only normally have this code called after the Character had moved, but you really won't notice any effect calling it every frame, so that's what we're going to do for now.

We'll set the viewport centre to the following x, y: ( [player x] + ([player width] / 2)), ( [player y] + ([player height] / 2)); in other words, to the player top/left position plus half the players width/height.


	viewport.update(player.position[0] + (player.dimensions[0]/2),
		player.position[1] + (player.dimensions[1]/2));

We'll also now make sure to erase anything on the Canvas from the last frame before beginning drawing by covering over the Canvas with a black rectangle, stretching from 0,0, to the Canvas width,height taken from the viewport screen property:


	ctx.fillStyle = "#000000";
	ctx.fillRect(0, 0, viewport.screen[0], viewport.screen[1]);

Modified drawing code

Now we're only drawing a portion of the map, and we're mocing the viewport around, we need to make some changes to some of our drawing code. First of all, our nested y, x loops for drawing the tiles must now start from the startTile and end at the endTile, instead of drawing from 0 until the edge of the map (mapW, mapH):


	for(var y = viewport.startTile[1]; y <= viewport.endTile[1]; ++y)
	{
		for(var x = viewport.startTile[0]; x <= viewport.endTile[0]; ++x)
		{

We're also changing the drawing code for drawing each tile to add the viewport offset value to the x and y coordinates of the tiles rectangle.


			ctx.fillRect( viewport.offset[0] + (x*tileW), viewport.offset[1] + (y*tileH),
				tileW, tileH);

Finally, we need to also add the offset values to the position at which our player will be drawn:


	ctx.fillRect(viewport.offset[0] + player.position[0], viewport.offset[1] + player.position[1],
		player.dimensions[0], player.dimensions[1]);

Finally, we're done! Try it out, and you should have a larger map to move around. Your Character will now stay centered on the screen, with the map appearing to move around it.

Page loaded in 0.011 second(s).