Improving performance to render a tile map

Started by Hanksha, March 25, 2014, 14:07:48

Previous topic - Next topic

Hanksha

Hi!

I'm developing a 2D tile based adventure platformer (what a long name), I'd like to have some advice from the wise about rendering a tile map with lwjgl.
I'm pretty new to openGL (just did some stuff with SDL2 in C++), I want to start my game with a solid bug free base before to go further.
I'm currently using VBO to render my textures, it gives good performance but I think there is a lot a improve to render the tile map.

My tiles are 16 x 16 and the window 1280 x 720, so in game it looks like this: (all sprites except the dwarf (player) are temporary)


On a map (the player switch from map to map) with not too much tile rendered the frame rate is around 100 to 130 fps (which is fine) I've try to render a map filling the screen (so 3600 tile to render) the fps drop to 76.

Here are the class involved to render the tilemap:

Class to create VBOs:

public class TexturedVBO {

	//Texture buffer ID
	private int vboTextureID;
	//Vertex buffer ID
	private int vboVertexID;
	private int vboVertexIDFlip;
	
	//dimension
	private float width, height;
	
	private TexturedVBO (int vertID, int vertIDFlip, int texID, float width, float height){
		vboVertexID = vertID;
		vboVertexIDFlip = vertIDFlip;
		vboTextureID = texID;
		
		this.width = width;
		this.height = height;
	}
	
	public static TexturedVBO loadTexture(Texture tex, float width, float height, float offsetX, float offsetY, float texW, float texH){

		
		//it passes the texture just to calculate properly the dimensions
		if(width == 0) width = tex.getImageWidth();
		if(height == 0) height = tex.getImageHeight();
		
		texW = texW / tex.getImageWidth(); 
		texH = texH / tex.getImageHeight();
		
		if(texW == 0) texW = tex.getWidth();
		if(texH == 0) texH = tex.getHeight();
		
		offsetX = offsetX / tex.getImageWidth();
		offsetY = offsetY / tex.getImageHeight();
		
		//Buffers
		FloatBuffer texCoords = BufferUtils.createFloatBuffer(2 * 4);
		FloatBuffer vertices = BufferUtils.createFloatBuffer(2 * 4);
		FloatBuffer verticesFlip = BufferUtils.createFloatBuffer(2 * 4);
		
		texCoords.put(new float[] 
				{ 
				offsetX, offsetY,
				offsetX + texW, offsetY,
				offsetX + texW, offsetY + texH,
				offsetX, offsetY + texH
				});
		texCoords.rewind();
		
		vertices.put(new float[]
				{
				0, 0,
				width, 0,
				width, height,
				0, height
				});
		vertices.rewind();
		
		verticesFlip.put(new float[]
				{
				0, 0,
				-width, 0,
				-width, height,
				0, height
				});
		verticesFlip.rewind();
		
		
		//VBO
		int vertID = glGenBuffers();
		glBindBuffer(GL_ARRAY_BUFFER, vertID);
		glBufferData(GL_ARRAY_BUFFER, vertices, GL_DYNAMIC_DRAW);
		glBindBuffer(GL_ARRAY_BUFFER, 0);
		
		int vertIDFlip =  glGenBuffers(); //to flip horizontally the texture
		glBindBuffer(GL_ARRAY_BUFFER, vertIDFlip);
		glBufferData(GL_ARRAY_BUFFER, verticesFlip, GL_DYNAMIC_DRAW);
		glBindBuffer(GL_ARRAY_BUFFER, 0);
		
		int texID =  glGenBuffers();
		glBindBuffer(GL_ARRAY_BUFFER, texID);
		glBufferData(GL_ARRAY_BUFFER, texCoords, GL_DYNAMIC_DRAW);
		glBindBuffer(GL_ARRAY_BUFFER, 0);
		
		return new TexturedVBO(vertID, vertIDFlip, texID, width, height);
	}
	
	public void render(double x, double y, boolean flip){
		glPushMatrix();
			glTranslated(x + (flip?width:0), y, 0);
			
			glBindBuffer(GL_ARRAY_BUFFER, flip?vboVertexIDFlip:vboVertexID);
			glVertexPointer(2, GL_FLOAT, 0, 0);
			
			glBindBuffer(GL_ARRAY_BUFFER, vboTextureID);
			glTexCoordPointer(2, GL_FLOAT, 0, 0);
			
			glDrawArrays(GL_QUADS, 0, 4);
			
		glPopMatrix();
	}
	
	public void dispose(){
		glDeleteBuffers(vboVertexID);
		glDeleteBuffers(vboTextureID);
	}

	public float getWidth() {
		return width;
	}

	public float getHeight() {
		return height;
	}
	
	
}


Method to render the tile map:

public void render(){
		
		for(int row = 0; row < numRowMap; row++ ){
			for(int col = 0; col < numColMap; col++){
				
				if(x + col * tileSize + tileSize < 0) continue;
				if(y + row * tileSize + tileSize < 0) continue;
				if(x + col * tileSize > WIDTH) continue;
				if(y + row * tileSize > HEIGHT) continue;
				
				currentTile = map[row][col];
				
				if(currentTile == 0) continue;
				
				
				int index = Math.abs(currentTile);
				
				if(x + col * tileSize > WIDTH + tileSize || x + col * tileSize < 0 - tileSize) continue;
				if(y + row * tileSize > HEIGHT + tileSize|| y + row * tileSize < 0 - tileSize) continue;
				
				if(currentTile < 0)
					flip = true;
				else
					flip = false;
				
				
				tilset[index].getVBO().render((int)x + col * tileSize, (int)y + row * tileSize , flip);
			}
		}
		
		
	}


Tile object:
public class Tile {

	private TexturedVBO vbo;
	private int type;
	
	//tile type
	public static final int NORMAL = 0;
	public static final int BLOCKED = 1;
	
	public Tile(TexturedVBO texturedVBO, int type){
		this.vbo = texturedVBO;
		this.type = type;
	}
	
	public TexturedVBO getVBO(){
		return vbo;
	}
	
	public int getType(){
		return type;
	}
}


To render the map itself (background layers, enemies...etc)

public void render() {
		//render background
		glBindTexture(GL_TEXTURE_2D, Content.getTexture("cave-bg").getTextureID());
		bg.render();
		
		//render tilemaps
		glBindTexture(GL_TEXTURE_2D, Content.getTexture("tileset1").getTextureID());
		layer1.render();
		tm.render();
		
		//render enemies
		glBindTexture(GL_TEXTURE_2D, Content.getTexture("enemies").getTextureID());
		for(Enemy e: enemies){
			e.render();
		}
	}


The game loop:
public void gameLoop() {
		
		startTime = System.nanoTime();
		timerFPS = System.nanoTime();
		
		while(!Display.isCloseRequested()){
			
			//calculate delta time
			dt = (System.nanoTime() - startTime) / 1000000000d;
			startTime =  System.nanoTime();
			
			//clear screen
			glClear(GL_COLOR_BUFFER_BIT);
			
			//update game logic
			update(dt);
			
			//render
			render();
			
			frameCounter++;
			
			if(timerFPS + 1000000000 < System.nanoTime()){
				FPS = frameCounter;
				frameCounter = 0;
				timerFPS = System.nanoTime();
			}
			
			//update display
			Display.update();
			Display.sync(TARGET_FPS);
		}
		gsm.dispose();
		Display.destroy();
		System.exit(0);
	
	}


And finally how I set up openGL:
private void setUpOpenGL() {
		glMatrixMode(GL_PROJECTION);
		glLoadIdentity();
		glOrtho(0, WIDTH, HEIGHT, 0, 1, -1);
		glMatrixMode(GL_MODELVIEW);
		glViewport(0, 0, Display.getWidth(), Display.getHeight());
		glEnable(GL_TEXTURE_2D);
		glEnable(GL_BLEND);
		glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
		System.out.println(glGetString(GL_RENDERER));
		glEnableClientState(GL_VERTEX_ARRAY);
		glEnableClientState(GL_TEXTURE_COORD_ARRAY);
		glClearColor(0f, 0f, 0f, 0f);
		
	}


So what am I doing to render a map and texture (a map is composed by one or more tile map and a background texture):
I bind the texture for each group of render that uses this texture, for the tiles all the tiles are in a POT image (PNG), same for player and enemies. So in one frame it binds 4 times, the background texture, then the tileset texture, then the player and finally the ennemies.
Maybe the way I'm doing is awful, so is there things I'm doing here that are really to avoid, and can I still improve the performances?

Cornix

As far as I can see you create an individual VBO for every tile in your game. If this is the case then thats something that you should change.
The good thing about VBO's is, that they can have any size. Batch all of your tiles (or as many as you feel comfortable with) into one large VBO and use that to render instead.

If you use several textures, then sort the data within the VBO by texture and call glDrawArrays once for each texture (but every time with different offset and length).
If you have 1 tile per texture then consider using a texture atlas (one big texture containing all tiles) or a texture array (for openGL 3.2 or above).
This way you will safe overhead from binding / unbinding textures.

Heres some options:
1) Have only 1 dynamic VBO and change its vertices every frame according to the visible tiles on screen.
This method will safe overhead from binding / unbinding different VBO's. Uploading vertices in a batch is also pretty fast.

2) Create several static VBO's, one for each part of the map, for example 128 x 128 tiles per VBO.
As your camera moves around only render those VBO's that are currently visible on screen.


For good performance always remember:
batch batch batch batch batch.

Hanksha

I have 64 tiles (for now) and they are all in one big texture which is loaded one time when the game launch (same for all the assets).

If I want to store more than one tile in a VBO how should I do?
Basically there vertices are all the same it's just the texcoords that change.
So I should add coordinates to my floatbuffer texCoords for each tile and then I create the VBO?

But my question then is how to use it when I render, how it will know what texcoord to use?

EDIT:
OK I think I understand the concept.
I could store the whole map into a VBO and then during the render I translate the position (since it all move at the same time) so it calls glDrawArray one time to draw the map.
But do openGL is going to do offscreen rendering in that case or it automatically avoid it?

abcdef

I'm not sure you understand the concepts fully. Imagine you have a large texture which consists of 4x4 small textures in it (one image with 4 sub images, I will use the term small textures to represent these sub images below), also imagine a 2x2 tile grid that you want to draw.

Now lets draw some tiles, I want the first 4 small textures (first row) of the large texture to be drawn in the 2x2 grid. First of all I work out the verticies of the 2x2 grid. We now need to assign some texture coordinates.

All the vertices in the top left should be assigned the texture coordinate of the 1st small texture (remember the larger texture has coordinates 0-1 in the x and y direction so the small textures will have a 0.25 width and height).

(0,0),(0,0.25),(0.25,0.25),(0.25,0)

Then the vertices in the top right would be assigned the next small texture

(0.25,0),(0.5,0),(0.5,0.25),(0.25,0.25)

and so on for the remaining 2 tiles

After adding the indexe array you should be able to build a VBO

And so on.

So build your VBO, bind your texture once and draw elements should do its job.

Now if you want to swap out the first tile for another, you leave all your vertices the same, you index array the same but you just change you texture coordinates to be the texture coordinates of the small textures you want to draw. All you need to do is then rebind the VAO, and then bind the new texture coordinates.

No offscreen rendering required, just a simple VAO update everytime you want to change the tile setup. Texture coordinates can be used more than once, its just a case of choosing the small textures to use.

Hanksha

I've changed my tile class (to not mistaken, the tile class is for the tile in the tileset, not the one drawn on screen) and instead of holding a vbo it holds only a float array with the texture coordinates in the big texture (tileset).

Then I create a VBO with containing the whole map: the vertices coordinates of each tile to be render and the corresponding texture coordinates (depending of the tile type).
To render I call glDrawArrays() once to draw the tile map.

I gained more than 100 fps, so not bad.

I saw your message after, I didn't use index array, what's the use of it?

(thanks for all the answers, it really help improving)

EDIT:
I think I understand the concept of index buffer, but I don't manage to implement it for the tile map, any tip?

Hanksha

Hi, little update with a new problem!
When I wrote the tile map code it was for a 16 x 16 tile. With my partner we decided to keep those 16x16 native tiles but to zoom it x2 in game (to have 32 x 32 tiles) it gives a better look to the game (see comparison here)
So I just change the tile size in my code but now I have this problem:


I think the image is self explanatory, I tried removing the padding in the tile set but it's the same.

Here is the code involved:


Tileset:
package Tiles;

import org.newdawn.slick.opengl.Texture;

import GameContent.Content;

public class TileSet {

	//meta data
	public static final int tileSize = 16;
	private static int[][] meta_rock ={	{1,1,1,1,1,1,1,1},
									  	{1,1,1,1,1,1,1,1},
									  	{1,1,1,1,1,1,1,1},
									  	{1,1,1,1,1,1,1,1},
									  	{1,1,1,1,1,1,1,1},
									  	{1,1,1,1,1,1,1,1},
									  	{1,1,1,1,1,1,1,1},
									  	{1,1,1,1,1,1,1,1} };
	
	public static Tile[] tileSet1;
	
	public static void loadTileSet(){
		tileSet1 = createTileSet(Content.getTexture("tileset1"), meta_rock, 8, 8, 1, 1, 1);
	}
	
	
	private static Tile[] createTileSet(Texture tileSet,int[][] meta,int numRow,int numCol,int offsetX,int offsetY, int spacing){
		int index = numRow * numCol;
		Tile[] tempTS = new Tile[index + 1];
		
		tempTS[0] = new Tile(null, 0);
		
		float texWidth = (float)tileSize / tileSet.getImageWidth();
		float texHeight = (float)tileSize / tileSet.getImageHeight();
		
		float x;
		float y;
		
		index = 1;
		
		for(int row = 0; row < numRow; row++){
			for(int col = 0; col < numCol; col++){
				
				x = (float)(offsetX + (tileSize + spacing) * col) / tileSet.getImageWidth();
				y = (float)(offsetY + (tileSize + spacing) * row) / tileSet.getImageHeight();
				
				float[]	texCoords = {x, y,
									 x + texWidth, y,
									 x + texWidth, y + texHeight,
									 x, y + texHeight};
				
				tempTS[index] = new Tile(texCoords, meta[row][col]);
				index++;
			}
		}
		
		
		
		return tempTS;
	}
	
}


Tile:
package Tiles;

public class Tile {
	private float[] texCoords;
	private int type;
	
	//tile type
	public static final int NORMAL = 0;
	public static final int BLOCKED = 1;
	
	public Tile(float[] texCoords, int type){
		this.type = type;
		this.texCoords = texCoords;
	}
	
	public float[] getTexCoords(){
		return texCoords;
	}
	
	public int getType(){
		return type;
	}
}


TileMap:
private void createVBO(){
		FloatBuffer texCoords = BufferUtils.createFloatBuffer(2 * 4 * numColMap * numRowMap);
		FloatBuffer vertices = BufferUtils.createFloatBuffer(2 * 4 * numColMap * numRowMap);
		
		for(int row = 0; row < numRowMap; row++ ){
			for(int col = 0; col < numColMap; col++){
				
				currentTile = map[row][col];
				
				if(currentTile == 0) continue;
				
				
				int index = Math.abs(currentTile);
				
				
				
				if(currentTile < 0)
					flip = true;
				else
					flip = false;
				
				
				
				
				texCoords.put(tilset[index].getTexCoords());
				
				vertices.put(new float[]
						{
						(float) (x + col * tileSize + (flip?tileSize:0)), (float) (y + row * tileSize),
						(float) (x + col * tileSize + tileSize * (flip?-1:1) + (flip?tileSize:0)), (float) (y + row * tileSize),
						(float) (x + col * tileSize + tileSize * (flip?-1:1) + (flip?tileSize:0)), (float) (y + row * tileSize + tileSize),
						(float) (x + col * tileSize + (flip?tileSize:0)), (float) (y + row * tileSize + tileSize)
						});
			}
		}
		texCoords.rewind();
		vertices.rewind();
		
		//indexBuffer.rewind();
		
		vboVertexID = glGenBuffers();
		glBindBuffer(GL_ARRAY_BUFFER, vboVertexID);
		glBufferData(GL_ARRAY_BUFFER, vertices, GL_STATIC_DRAW);
		glBindBuffer(GL_ARRAY_BUFFER, 0);
		
		vboTextureID =  glGenBuffers();
		glBindBuffer(GL_ARRAY_BUFFER, vboTextureID);
		glBufferData(GL_ARRAY_BUFFER, texCoords, GL_STATIC_DRAW);
		glBindBuffer(GL_ARRAY_BUFFER, 0);
	}

public void render(){
		
		glPushMatrix();
		glTranslated((int)x, (int)y, 1);
		
		glBindBuffer(GL_ARRAY_BUFFER, vboVertexID);
		glVertexPointer(2, GL_FLOAT, 0, 0);
		
		glBindBuffer(GL_ARRAY_BUFFER, vboTextureID);
		glTexCoordPointer(2, GL_FLOAT, 0, 0);
		
		glDrawArrays(GL_QUADS, 0, 4 * 100 * 100);
		
		glPopMatrix();
		
		
	}


Any idea of how I could fix this?

Cornix

If I had to take a guess its the linear mag filter used in the texture.
Change it to GL_NEAREST and try again.

Hanksha

It fixed the problem, I was using the slick util TextureLoader, I didn't see I could precise the texture filter.

Thanks!

By the way, now that i'm done with that, someone knows a good lwjgl tutorial to use shaders in order to do a simple dynamic lightning for my game?

Cornix

Dynamic lightning is never simple.
You do not need any "lwjgl" tutorials on shaders either because lwjgl does not add any new code to it. Just read any tutorial on shaders you like.

Hanksha

Sure it's never simple.
What I want to achieve is not so complicated.
In this screenshot:


I'm using a simple texture that is stretch with a circle in the middle that follow the player.
It's not really looking good, what I'd like to have it's a sort of light that follows the player (no need to cast shadows etc...), and there will be other elements like torches that you can place on wall...etc that will also be source of light (a circle like for the player, but gives a warmer light).
Is there a way to achieve this without going too deep in shaders and dynamic light stuff?

Cornix

If you know that there will never be too many light sources on the screen at the same time this can be done in a very simple way.

Just have an array of light source coordinates and size in your shader. Then, for every vertex, determine the distance from the vertex to all of these light sources and change the color depending on the distance and the size of the light source.
This would not be per pixel lightning but since you use a tilemap with fairly small tiles it should not be noticable, and it will be quick and simple.


Hanksha

I finally managed to get something nice:


Thanks for the help : )